Class: Rubino::UI::BottomComposer

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

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • input_queue (Interaction::InputQueue)

    completed lines are pushed here; the agent loop / REPL drain it (steering). Required for the reader to do anything useful.

  • input (IO) (defaults to: $stdin)

    keystroke source (default $stdin).

  • output (IO) (defaults to: $stdout)

    where the prompt + above-output is written (default $stdout).

  • prompt (String) (defaults to: PROMPT)

    the input-line prefix after the rail — the plain “❯ ” caret (may contain ANSI color). Defaults to the bare caret for standalone use / tests. The mode/skill chip that used to ride here lives in the STATUS BAR now (the Rail rubino redesign).

  • rail (String, nil) (defaults to: nil)

    the one-column brand rail (the red “▍”) drawn as the FIRST column of EVERY input row — the first row AND each wrapped/newline continuation — so a multi-row draft reads as one block. May carry ANSI color. nil/empty ⇒ no rail (standalone / tests / the cooked fallback), with the exact pre-rail geometry. The rail is pure input-block chrome: committed echoes (“<prompt><line>”) never carry it, so scrollback stays clean.

  • on_ctrl_o (#call, nil) (defaults to: nil)

    invoked when the user presses Ctrl+O — the CLI uses it to REVEAL the last retained reasoning buffer as a ‘┊` aside committed into scrollback. The composer never formats reasoning itself; it only dispatches the keystroke. nil = no-op.

  • on_mode_cycle (#call, nil) (defaults to: nil)

    invoked when the user presses Shift+Tab to cycle the mode. The callback owns the mode logic (persist + emit the transition footer) and RETURNS the freshly-built STATUS-BAR line (the mode token leads it), which the composer adopts and redraws — the mode lives in the status bar now, not in a prompt chip. nil return ⇒ no status change (e.g. the yolo arm toast). The composer holds no mode knowledge itself. nil = Shift+Tab is a no-op.

  • echo (Symbol) (defaults to: :queued)

    how a submitted line is echoed into scrollback: :queued (default) is the IN-TURN composer — Enter INTERRUPTS the active turn and sends the line as the next turn (the default), so it never commits an echo here (the next turn’s prompt echo is committed by the chat loop when it runs); :prompt prints the prompt + the line (e.g. “default ❯ <line>”) — the idle case, where the line IS the user’s message and should read back like a normal shell submit.

  • on_interrupt (#call, nil) (defaults to: nil)

    invoked when the user presses Enter to submit a line WHILE a turn is active. The chat loop wires this to the active turn’s cancel so the current turn is interrupted and the just-submitted line runs as the next turn immediately. nil ⇒ no interrupt (the line is simply queued, as before).

  • pending_queued (Array<String>, nil) (defaults to: nil)

    shared stack of messages the user EXPLICITLY queued (Alt+Enter / “/queued <msg>”) while a turn is active. Rendered as “⏳ queued: <msg>” rows ABOVE the input (live region, never committed). Shared across the per-turn composers by the chat loop so the indicator survives a composer teardown and is removed/committed as a normal message when the queued item’s turn runs. nil ⇒ a private list (standalone / tests).

  • status_line (String, nil) (defaults to: nil)

    the styled model/context line pinned BELOW the input row (see StatusBar). nil/empty ⇒ no bar. Updated at turn boundaries via #set_status — never per-delta.

  • max_input_rows (Integer, nil) (defaults to: nil)

    cap on the input block’s visual rows (config display.input_max_rows); nil ⇒ MAX_INPUT_ROWS.

  • paste_store (UI::PasteStore, nil) (defaults to: nil)

    the per-session paste store behind the file-backed paste pipeline: a large paste collapses to a “[Pasted text #N +M lines]” placeholder registered here (expanded to the full body at the chat loop’s message-build seam), and backspace on a placeholder deletes it WHOLE. Shared across the per-turn composers by the chat command, like pending_queued. nil ⇒ every paste inlines into the buffer (standalone / tests), as before.

  • on_double_esc (#call, nil) (defaults to: nil)

    invoked when the user presses Esc twice within DOUBLE_ESC_SECONDS at the IDLE prompt — the Esc-Esc rewind chord. Wired only on the IDLE composer (the chat loop opens the rewind picker from it); the in-turn composer leaves it nil, so Esc keeps no double-tap meaning during a turn. With a menu open the first Esc keeps its dismiss meaning AND arms the chord, so Esc-Esc over a menu reads dismiss-then-rewind. The hook runs on the reader thread — callers must only flip a flag, never block or take the composer’s locks (the idle loop drains it, like the Ctrl+C trap).



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

.currentObject

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

#bufferObject (readonly)

The current editable buffer (test/inspection helper).



659
660
661
# File 'lib/rubino/ui/bottom_composer.rb', line 659

def buffer
  @buffer
end

#cardsObject (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

#outputObject (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.

Returns:

  • (Boolean)


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_terminalObject

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_streamObject

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_turnObject

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_inputObject

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_streamObject

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_turnObject

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_open? && !@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_inputObject

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

True when the /command + @file completion menu is open (inspection helper; the reader/specs check it to branch Tab/Enter/Esc handling).

Returns:

  • (Boolean)


611
612
613
# File 'lib/rubino/ui/bottom_composer.rb', line 611

def menu_open?
  @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.

Returns:

  • (Boolean)


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

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

#resizeObject

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

#resumeObject

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_budgetObject

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

#startObject

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_rowObject

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

#stopObject

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).

Returns:

  • (Boolean)


472
473
474
# File 'lib/rubino/ui/bottom_composer.rb', line 472

def streaming?
  @content_streaming
end

#suspendObject

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_rowsObject

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