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 "
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_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, on_agent_cycle: nil, on_escape: nil, on_busy_command: 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 QUEUES the line (the Claude-Code type-ahead default, #421): the active turn keeps running and the line shows a live “⏳ queued:” indicator, committed by the chat loop when its turn runs, so it never commits an echo here; :prompt prints the prompt + the line (e.g. “default ❯ <line>”) — the idle case, where the line IS the user’s message and reads back like a shell submit.

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

    invoked when the user presses ESC while a turn is active (#421 — Esc is the interrupt; Enter queues). The chat loop wires this to the active turn’s cancel (runner.cancel!) so the current turn is interrupted and the head of the queue runs next. nil ⇒Esc is a no-op mid-turn (the composer just queues on Enter).

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



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

.currentObject

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

#bufferObject (readonly)

The current editable buffer (test/inspection helper).



736
737
738
# File 'lib/rubino/ui/bottom_composer.rb', line 736

def buffer
  @buffer
end

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

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



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.

Returns:

  • (Boolean)


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



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



531
532
533
# File 'lib/rubino/ui/bottom_composer.rb', line 531

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.



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_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



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



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



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_regionObject

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_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 "\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_hintObject

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



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

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

Returns:

  • (Boolean)


688
689
690
# File 'lib/rubino/ui/bottom_composer.rb', line 688

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.



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.

Returns:

  • (Boolean)


517
518
519
# File 'lib/rubino/ui/bottom_composer.rb', line 517

def partial?
  !@partial.empty?
end

#pastelObject



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

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

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



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

#resumeObject

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



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

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



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

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

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



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

Returns:

  • (Boolean)


524
525
526
# File 'lib/rubino/ui/bottom_composer.rb', line 524

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



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



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