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). Sized for the tallest legitimate partial: the GROWING table live-render — a fitted bordered table of the header + the last LIVE_TAIL_ROWS (3) completed rows is top-border + header + header-separator + 3 rows + bottom-border = 7 physical rows. Prose/reasoning tails arrive pre-capped to LIVE_TAIL_ROWS upstream, so this ceiling only ever clamps a runaway.

7
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, on_back: nil, on_idle_interrupt: nil, attached: false) ⇒ 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).



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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/rubino/ui/bottom_composer.rb', line 180

def initialize(input_queue:, input: $stdin, output: $stdout, prompt: PROMPT, # rubocop:disable Metrics/MethodLength,Metrics/AbcSize -- one assignment per injected collaborator/hook; a wide DI constructor, not a complex body
               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, on_back: nil, on_idle_interrupt: nil,
               attached: false)
  @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
  # Invoked when Ctrl+C (\x03) is read at the IDLE prompt (#551). The raw
  # reader runs under +raw(intr: true)+, but on Darwin/macOS (and other
  # platforms) that does NOT reliably keep ISIG on — Ctrl+C is swallowed by
  # the terminal discipline WITHOUT raising SIGINT and WITHOUT delivering a
  # byte the loop could act on. So we no longer depend on a SIGINT trap for
  # the in-band interrupt: \x03 is read as a byte here (ISIG-off raw still
  # delivers it) and routed to this hook, which drives the existing idle
  # two-tap clear/exit. nil ⇒ the legacy ignore (the in-turn composer uses
  # @on_interrupt instead). Runs on the reader thread — flip a flag only.
  @on_idle_interrupt = on_idle_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
  # Optional "back out" gesture: ← (or Ctrl+B) on an EMPTY prompt fires this
  # instead of a no-op cursor move. The agent-attach view wires it to detach
  # to the main timeline, so going back is a single keypress (or the picker's
  # "◂ main" row) rather than a typed /detach. nil ⇒ ← stays a plain cursor move.
  @on_back = on_back
  # 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, @agent_menu = build_menus(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
  # The editable input line — text + cursor + the pure codepoint editing
  # math — extracted into Composer::InputLine so it lives in one unit-tested
  # model instead of the composer. Read via #buffer/#cursor; every mutation
  # goes through @input_line under the @render mutex, then a #redraw.
  @input_line  = Composer::InputLine.new
  @partial     = +"" # live, un-committed streamed line shown above the prompt
  # The live TURN activity (the animated facet: "◆ writing · 47s · 18 tools
  # · ~202 tok"), set by the CLI status ticker via #set_turn_status. When
  # non-empty the footer (#status_row) prepends it to the model/ctx bar so
  # there is ONE status bar during a turn instead of a separate row above
  # the prompt. Cleared at turn end so the footer reverts to model/ctx.
  @turn_status = +""
  # 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 rows, fed by UI::CLI#set_subagent_cards from the
  # BackgroundTasks registry. Now rendered BELOW the input (next to the
  # status footer) by @subagent_panel — the single live representation of
  # running children, no longer a duplicate block above the timeline.
  @cards = []
  @subagent_panel = Composer::SubagentPanel.new(agent_menu: @agent_menu, cards: -> { @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
  init_takeover_state(attached: attached)
  @cols = compute_cols
end

Class Attribute Details

.currentObject

Returns the value of attribute current.



425
426
427
# File 'lib/rubino/ui/bottom_composer.rb', line 425

def current
  @current
end

Instance Attribute Details

#cardsObject (readonly)

The card rows currently shown (test/inspection helper).



1176
1177
1178
# File 'lib/rubino/ui/bottom_composer.rb', line 1176

def cards
  @cards
end

#focused_agent_idObject (readonly)

The agent currently allowed to paint (the focused view). :main when not attached to any sub. Exposed for the while-attached switcher line and tests.



1091
1092
1093
# File 'lib/rubino/ui/bottom_composer.rb', line 1091

def focused_agent_id
  @focused_agent_id
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.



1181
1182
1183
# File 'lib/rubino/ui/bottom_composer.rb', line 1181

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)


414
415
416
417
418
# File 'lib/rubino/ui/bottom_composer.rb', line 414

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.



437
438
439
440
441
442
443
444
445
446
447
# File 'lib/rubino/ui/bottom_composer.rb', line 437

def self.run_in_terminal
  composer = current
  return yield unless composer

  composer.suspend
  begin
    yield
  ensure
    composer.resume
  end
end

Instance Method Details

#agent_menu_open?Boolean

Returns:

  • (Boolean)


1193
1194
1195
# File 'lib/rubino/ui/bottom_composer.rb', line 1193

def agent_menu_open?
  @agent_menu.open?
end

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



1034
1035
1036
1037
1038
1039
# File 'lib/rubino/ui/bottom_composer.rb', line 1034

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.



1055
1056
1057
# File 'lib/rubino/ui/bottom_composer.rb', line 1055

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.



983
984
985
# File 'lib/rubino/ui/bottom_composer.rb', line 983

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.



1008
1009
1010
1011
1012
1013
1014
# File 'lib/rubino/ui/bottom_composer.rb', line 1008

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

#bufferObject

The current editable text (test/inspection helper + the draft accessor chat_command reads). Delegates to the input-line model.



1339
# File 'lib/rubino/ui/bottom_composer.rb', line 1339

def buffer = @input_line.text

#build_menus(completion_source) ⇒ Object



1189
1190
1191
# File 'lib/rubino/ui/bottom_composer.rb', line 1189

def build_menus(completion_source)
  [CompletionMenu.new(completion_source), AgentMenu.new]
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.



1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
# File 'lib/rubino/ui/bottom_composer.rb', line 1390

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

#clear_pending_takeoverObject

Drops the queued takeover + its draft snapshot. Must be called under



617
618
619
620
621
# File 'lib/rubino/ui/bottom_composer.rb', line 617

def clear_pending_takeover
  @pending_takeover   = nil
  @takeover_snapshot  = nil
  @on_takeover_resume = nil
end

#clear_quit_pendingObject

Clears the EOF/quit flag (the idle loop consumes it once it has acted on the EOF). Lets a fresh composer session start clean if the same instance is reused.



1156
# File 'lib/rubino/ui/bottom_composer.rb', line 1156

def clear_quit_pending = (@quit_pending = false)

#clear_turn_statusObject



905
906
907
# File 'lib/rubino/ui/bottom_composer.rb', line 905

def clear_turn_status
  set_turn_status("")
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.



959
960
961
962
963
964
965
966
# File 'lib/rubino/ui/bottom_composer.rb', line 959

def commit_queued(msg)
  removed = false
  @render.synchronize do
    removed = !@queued.remove(msg).nil?
    redraw if removed
  end
  removed
end

#drain_inflight_into_draftObject

COMPLETE the request-time draft snapshot just before the dropdown opens, on the reader thread (the only thread allowed to getc @input). Runs BETWEEN raw sessions, so @input is in cooked mode but the bytes the human typed before the auto-open raced in are still queued in the kernel TTY buffer — unread, because the wake-pipe branch in #reader_session breaks the loop without getc‘ing a co-ready @input. We:

1. reset buffer/cursor to the request-time SNAPSHOT baseline, so a
   programmatic edit made after the snapshot (the "can't tear it" race)
   is discarded, exactly as before;
2. DRAIN the pending bytes through the normal #handle_key path so they
   land in the draft like any other keystroke (a non-blocking
   IO.select(0) gate + #getc loop — we only consume what is ALREADY
   queued, never block waiting for more, and stop the instant the queue
   is empty or a key submits/quits);
3. RE-SNAPSHOT the now-complete buffer/cursor under @render, so the
   restore after the dropdown closes returns the FULL draft and the
   dropdown starts with an empty input queue — no draft byte can leak
   into TTY::Prompt's filter.

The whole thing is a no-op when nothing was snapshotted or @input can’t be drained (no fileno / closed) — the dropdown then just runs as before.



724
725
726
727
728
729
730
731
732
733
734
# File 'lib/rubino/ui/bottom_composer.rb', line 724

def drain_inflight_into_draft
  baseline = @render.synchronize { @takeover_snapshot }
  return unless baseline

  @render.synchronize do
    buf, cur = baseline
    @input_line.replace(buf.to_s).move_to(cur.to_i)
  end
  drain_pending_input
  @render.synchronize { @takeover_snapshot = [buffer.dup, cursor] }
end

#drain_pending_inputObject

Feed every byte ALREADY queued on @input through #handle_key, then stop —a bounded, non-blocking drain. For a real TTY we gate each #getc on a zero-timeout #wait_readable: it reports readable ONLY while bytes are buffered, so the loop drains the in-flight keystrokes and exits the moment the queue empties — it never blocks for more input. A StringIO (tests / standalone) can’t #wait_readable, but its #getc returns nil at the end without blocking, so we drain it with a plain #getc loop. A key that submits/quits ends the drain (the draft is gone anyway); any IO hiccup (non-tty / closed / EOF) just ends it quietly.



764
765
766
767
768
769
770
771
772
773
774
775
# File 'lib/rubino/ui/bottom_composer.rb', line 764

def drain_pending_input
  selectable = real_io_input?
  loop do
    break if selectable && !@input.wait_readable(0)

    ch = @input.getc
    break if ch.nil?
    break if handle_key(ch)
  end
rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
  nil
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



1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
# File 'lib/rubino/ui/bottom_composer.rb', line 1209

def draw_input
  # Refresh the width from the live terminal on the CHEAP keystroke path
  # too, exactly as #render_frame does. @cols was only recomputed at init
  # and on SIGWINCH, but the trap can read winsize BEFORE the terminal has
  # committed the new size (a drag coalesces several SIGWINCHes; the kernel
  # updates the pty winsize asynchronously), so #resize could record a
  # STALE width. With @cols stale a wrapping line lays out as ONE logical
  # row while the physical terminal wraps it onto a SECOND line the
  # single-row \r\e[2K clear never erases — so each keystroke re-emitted
  # the first row and the duplicate physical wrap-row stair-stepped into
  # scrollback (#481). Adopting only a freshly-read POSITIVE width keeps a
  # transient zero/blank winsize from collapsing the budget (#95).
  fresh = live_winsize_cols
  @cols = fresh if fresh
  # If the live width differs from the width the on-screen input block was
  # laid out at, the terminal has REFLOWED that block: a line that fit on
  # one logical row at the previous width now spans more physical rows (or
  # fewer). #input_drawn recorded the OLD width's caret-row count, so the
  # in-place #clear_input_block would walk up too few rows and leave the
  # reflowed top fragment committed as a stale "❯" row — the #481 repro
  # (a stale-width SIGWINCH redraw followed by keystrokes that wrap). #496
  # refreshed @cols here so the NEW layout is correct, but did NOT clear
  # the rows the line occupied at the previous width. Widen the clear to
  # the MAX of the old-width and live-width caret-row counts so no stale
  # row from the prior width survives, then lay out at the live width.
  if @input_cols && @input_cols != @cols
    # Single resize: clear the MAX of the old-width and live-width footprints
    # so the reflowed top fragment can't survive. Chained resize (#481, the
    # residual): a row the PREVIOUS reflow under-cleared (e.g. the 120-col
    # footprint stranded by the 50-col frame on a 120→50→40 walk) is covered
    # by neither the 50- nor the 40-col count, so also fold in the WORST-CASE
    # footprint carried across the whole resize chain (@input_above_high_water).
    @input_above_high_water = [
      @input_above_high_water,
      rows_above_caret_at(row_budget_for(@input_cols)),
      rows_above_caret_at(row_budget_for(@cols))
    ].max
    @region.widen_input_above(@input_above_high_water)
  end
  rows, caret_row, caret_col = visible_input_rows
  status = status_row
  # Rows drawn BELOW the input, top→bottom: the subagent panel (one calm
  # representation of the running children) then the status footer. A fresh
  # array so appending the status never mutates the panel's own rows.
  below_rows = below_input_rows
  below_rows += [status] if status

  @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 || !below_rows.empty?
  end
  # Clamp each below-row to one column SHORT of the width (#fit_row): a glyph
  # in the final column arms the terminal's deferred auto-wrap, and the
  # trailing CRLF then double-scrolls — which slides the block out from under
  # the next frame's relative clear and strands a ghost ❯ row. Same rule
  # LiveRegion#emit_row uses for the rows above the input.
  below_rows.each_with_index do |row, i|
    @output.print("\r\e[2K#{fit_row(row)}")
    @output.print("\r\n") if i < below_rows.length - 1
  end

  below = (rows.length - 1 - caret_row) + below_rows.length
  park_caret(rows, caret_col, below)
  @region.input_drawn(above: caret_row, below: below)
  # Remember the width this block was laid out at so the NEXT frame can
  # detect a reflow and widen the clear (#481, see above).
  @input_cols = @cols
  # Carry the worst-case above-caret footprint forward so a SUBSEQUENT
  # reflow clears over every width this block has occupied since the last
  # clean full draw (#481, chained resize). The just-drawn caret_row counts
  # too: a wider previous frame strands rows a narrower one's own clear
  # misses, so the high-water must never shrink between clean draws.
  @input_above_high_water = [@input_above_high_water, caret_row].max
  @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).



995
996
997
998
999
1000
1001
# File 'lib/rubino/ui/bottom_composer.rb', line 995

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



1022
1023
1024
1025
1026
1027
# File 'lib/rubino/ui/bottom_composer.rb', line 1022

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

#enter_takeover_modeObject

The TERMINAL-STATE half of #suspend, WITHOUT touching the reader thread’s lifecycle (caller owns that): flip @suspended, restore the REAL $stdout (so tty-screen probes the real terminal, not the write-only StdoutProxy), leave raw mode, drop the WINCH/CONT traps, and clear the prompt rows. The typed buffer draft is left untouched (preserved for the resume redraw). Shared by #suspend (which stops the reader first) AND the mid-turn auto-open running ON the reader thread (which cannot stop_reader without joining itself, so it breaks its own select loop instead and calls this).



527
528
529
530
531
532
533
534
535
# File 'lib/rubino/ui/bottom_composer.rb', line 527

def enter_takeover_mode
  @suspended    = true
  @saved_stdout = $stdout
  $stdout       = @output
  restore_winch_trap
  restore_cont_trap
  @input.cooked! if tty?
  @render.synchronize { clear_live_region_to_clean_line }
end

#final_drain_into_draftObject

RESIDUAL B: a FINAL non-blocking drain run AFTER #enter_takeover_mode and immediately BEFORE the dropdown block reads $stdin. #drain_inflight_into_draft (above) catches the bytes queued at REQUEST time, but the human may keep typing during the suspend transition (cooked!/clear-region/dropdown setup) —those bytes land in the kernel TTY queue AFTER that first snapshot. This second pass drains whatever has ACCRUED since, onto the CURRENT draft (no baseline reset — the first drain’s bytes stay), and re-snapshots so the restore still returns the full draft and the picker filter starts empty. Bounded/non-blocking exactly like the first pass (it shares #drain_pending_input). It NARROWS — does not eliminate — the window: the sub-instant between this drain and TTY::Prompt’s own first getc is irreducible without blocking or pre-empting the picker’s stdin grab.



748
749
750
751
752
753
# File 'lib/rubino/ui/bottom_composer.rb', line 748

def final_drain_into_draft
  return unless @render.synchronize { @takeover_snapshot }

  drain_pending_input
  @render.synchronize { @takeover_snapshot = [buffer.dup, cursor] }
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.



856
857
858
859
860
861
862
# File 'lib/rubino/ui/bottom_composer.rb', line 856

def finalize_region
  @render.synchronize do
    @partial = +""
    @region.clear
    redraw
  end
end

#fits?(str) ⇒ Boolean

True when str‘s visible width fits the status row (one column of slack).

Returns:

  • (Boolean)


1501
1502
1503
# File 'lib/rubino/ui/bottom_composer.rb', line 1501

def fits?(str)
  display_width(str.gsub(ANSI_RE, "")) <= @cols - 1
end

#flush_parked_writesObject

Replays the committed lines #print_above parked while @suspended, in arrival order, as one quiet batch before the prompt redraws — so a turn that kept streaming behind the dropdown shows its output the instant the dropdown closes, with no interleaving. Must be called under @render.



559
560
561
562
563
564
565
566
# File 'lib/rubino/ui/bottom_composer.rb', line 559

def flush_parked_writes
  parked = @parked_writes
  @parked_writes = nil
  return unless parked && !parked.empty?

  @partial = +""
  parked.each { |str| render_frame(committed: str) }
end

#focus_agent!(id) ⇒ Object

Focus-gating seam (tmux-style unified render): the REPL calls this on every view switch — ‘focus_agent!(sub_id)` on attach, `focus_agent!(:main)` on detach back to main. Only frames whose `origin:` equals the focused id paint; #print_above / #set_partial / #set_turn_status / #set_cards DROP a non-focused agent’s frames so a background agent (the main loop while attached, or a sub while at main) keeps running and recording its session but does not paint over the focused view. The raw reader is untouched — the user keeps typing into the focused agent’s prompt. The write takes @render so a concurrent gated paint can’t read a half-updated focus; calling it off a composer is a no-op (the CLI guards with ‘&.`). The focused id also marks the FOCUSED sub in the compact switcher line (#87). nil ⇒ :main.



1085
1086
1087
# File 'lib/rubino/ui/bottom_composer.rb', line 1085

def focus_agent!(id)
  @render.synchronize { @focused_agent_id = id || :main }
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.



1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
# File 'lib/rubino/ui/bottom_composer.rb', line 1521

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"
    if agent_menu_open?
      accept_agent_menu
      return nil
    end
    # 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
    return nil if enter_view_subagent

    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 "\x03" then handle_ctrl_c # Ctrl+C: interrupt the turn / idle two-tap (#551)
  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 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).



1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
# File 'lib/rubino/ui/bottom_composer.rb', line 1125

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!
      @input_line.clear
      @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

#init_takeover_state(attached: false) ⇒ Object

Mid-turn auto-open (Option A) + R1 write-park state, factored out of #initialize. @parked_writes buffers committed stream lines #print_above receives while @suspended (flushed in order on resume); @pending_takeover is the dropdown block queued for the input thread and @takeover_snapshot the [buffer, cursor] draft captured when it was queued (restored verbatim after the dropdown closes).



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/rubino/ui/bottom_composer.rb', line 332

def init_takeover_state(attached: false)
  # Set when the reader sees an EOF/quit (empty-buffer Ctrl+D or a closed
  # stdin) so the idle poll loop can OBSERVE it and return nil (EOF),
  # mirroring how #idle_interrupt surfaces a Ctrl+C. Without this the reader
  # thread just stops and the idle loop spins forever (the Ctrl+D hang).
  @quit_pending      = false
  @saved_stdout      = nil # the real $stdout, parked while suspended for a takeover
  @wake_pipe         = nil # self-pipe write end that asks the reader to run a takeover
  @parked_writes     = nil
  @pending_takeover  = nil
  @takeover_snapshot = nil
  @input_cols        = nil # width the on-screen input block was laid out at (#481)
  # WORST-CASE above-caret row count the current input block has occupied
  # across EVERY width it's been laid out at since the last CLEAN full draw
  # (#481, chained resize). A single resize-then-wrap is recovered by the
  # old-vs-live max in #draw_input, but a SECOND consecutive SIGWINCH
  # (120→50→40) strands the row the 50-col frame itself under-cleared from
  # the 120-col footprint — neither the 50- nor the 40-col count covers it.
  # We carry the max footprint forward here and clear up to it on the next
  # reflow, so the clear walks the worst case across the WHOLE resize chain.
  # Reset to 0 when a full live-region clear blanks the block (no residue
  # survives a clean frame), so it never over-clears past a clean draw.
  @input_above_high_water = 0
  # An optional callable the CLI registers (UI::CLI#auto_open_human_ask) so
  # that the SUBAGENT CARD block — whose last row is the aggregated
  # `⛔N subagents waiting on you` hint — is REPAINTED from the live registry
  # the instant the dropdown takeover ends. #enter_takeover_mode clears the
  # live region (the cards with it) and #leave_takeover_mode only redraws the
  # prompt, so without this the ⛔N count vanishes for the rest of the turn
  # whenever ≥1 child is still awaiting_human after the takeover (the human
  # answered one of several, or cancelled). Run AFTER resume (outside the
  # render lock — it calls #set_cards, which re-takes it), then cleared.
  @on_takeover_resume = nil
  # True only while #run_pending_takeover owns the suspend/resume lifecycle
  # on the reader thread. The dropdown it runs calls @ui.select/@ui.ask,
  # which wrap themselves in BottomComposer.run_in_terminal — its ensure
  # fires #suspend then #resume. With the reader-thread takeover ALREADY
  # suspended-for-takeover (and intentionally NOT stopped — it is us), that
  # nested #resume would spawn a SECOND reader thread and reassign @wake_pipe
  # mid-takeover, leaving two readers contending for raw $stdin and the next
  # #request_takeover wake signal landing on a torn reader — the auto-open
  # then fires exactly ONCE per session. While this flag is set #suspend and
  # #resume are no-ops, so run_in_terminal nests harmlessly inside the
  # takeover the reader already drives.
  @in_takeover = false
  # True from the moment #run_pending_takeover adopts a queued block until
  # the dropdown loop has fully resolved and the composer resumed. The
  # one-at-a-time guard #request_takeover honours so two near-simultaneous
  # asks can never spawn OVERLAPPING takeover loops (#486); distinct from
  # @in_takeover (which only neuters the nested suspend/resume).
  @takeover_active = false

  # Focus-gating (tmux-style unified render): EVERY agent — the main loop and
  # each background subagent — paints through its own UI::CLI, and each frame
  # carries an `origin:` (the CLI's agent_id). @focused_agent_id names the ONE
  # agent whose frames may paint the screen right now; print_above /
  # set_partial / set_turn_status / set_cards DROP a frame whose origin isn't
  # the focused one (the spinner streams through set_partial too), so a
  # non-focused agent keeps running and recording its session but paints
  # nothing. Frames are NOT parked: a switch replays the newly-focused agent's
  # full session from the store, so a parked raw line would only duplicate it.
  # Distinct from @suspended (run_in_terminal's takeover, which stops the
  # reader): the reader stays fully live so the user keeps typing into the
  # focused agent. @replaying exempts the attach/detach REPLAY (the focused
  # view the user is meant to see) from the gate — see #with_replay_exempt.
  #
  # SEEDED from the persistent host attach-state (`attached:` — the focused
  # sub's id, or nil/false when at main): the REPL builds a FRESH composer
  # per idle iteration / per turn, so a flag set imperatively at attach time
  # on the previous composer would be lost the moment the loop recreates one
  # (the focused agent's live tail never owns the screen — #82). Which agent
  # is focused lives on the host (@attached_id), so the composer RECONCILES
  # its focus from that at construction — every composer that owns the screen
  # while attached starts already focused on the right agent, so the
  # while-attached switcher line marks it (#87). :main is the default focus.
  @focused_agent_id = attached || :main
  @replaying        = false
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.



1507
1508
1509
# File 'lib/rubino/ui/bottom_composer.rb', line 1507

def interrupt_hint
  @interrupt_hint ||= pastel.dim(ESC_INTERRUPT_HINT)
end

#join(left, right) ⇒ Object

Joins two status pieces with the two-space separator the bar uses, collapsing to the non-empty side when one is blank (no leading gap).



1493
1494
1495
1496
1497
1498
# File 'lib/rubino/ui/bottom_composer.rb', line 1493

def join(left, right)
  return right if left.empty?
  return left if right.empty?

  "#{left}  #{right}"
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.



1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
# File 'lib/rubino/ui/bottom_composer.rb', line 1363

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

#leave_takeover_modeObject

The TERMINAL-STATE half of #resume, WITHOUT restarting the reader (caller owns that): restore the StdoutProxy, re-arm the traps, FLUSH any stream lines parked while suspended (R1 write-park) so they land in scrollback in order, then redraw the prompt from the preserved buffer. Re-entering raw mode is done by the caller’s reader (its ‘@input.raw` block).



542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/rubino/ui/bottom_composer.rb', line 542

def leave_takeover_mode
  @suspended    = false
  $stdout       = @saved_stdout if @saved_stdout
  @saved_stdout = nil
  install_winch_trap
  install_cont_trap
  @render.synchronize do
    @output.print(PASTE_ON)
    flush_parked_writes
    draw_input
  end
end

#main_render_suppressed?Boolean

Returns:

  • (Boolean)


1093
# File 'lib/rubino/ui/bottom_composer.rb', line 1093

def main_render_suppressed? = @focused_agent_id != :main

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

Returns:

  • (Boolean)


1185
1186
1187
# File 'lib/rubino/ui/bottom_composer.rb', line 1185

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.



1329
1330
1331
1332
1333
1334
1335
# File 'lib/rubino/ui/bottom_composer.rb', line 1329

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)


969
970
971
# File 'lib/rubino/ui/bottom_composer.rb', line 969

def partial?
  !@partial.empty?
end

#pastelObject



1511
1512
1513
# File 'lib/rubino/ui/bottom_composer.rb', line 1511

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.



1166
1167
1168
1169
1170
1171
1172
1173
# File 'lib/rubino/ui/bottom_composer.rb', line 1166

def prefill(text)
  @render.synchronize do
    @menu.close!
    @input_line.replace(text.to_s)
    @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).



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
# File 'lib/rubino/ui/bottom_composer.rb', line 811

def print_above(str, origin: :main)
  @render.synchronize do
    # R1 write-park: while SUSPENDED (an approval / ask / auto-open dropdown
    # owns the real terminal) the agent thread may STILL be streaming. A raw
    # render_frame here would paint the committed line + prompt rows straight
    # OVER the interactive dropdown and interleave the two frames. So PARK the
    # committed line in @parked_writes (the live #set_partial / #set_cards
    # already drop their frames while suspended); #resume flushes the parked
    # lines in order under @render, so the stream and the dropdown never mix.
    if @suspended
      (@parked_writes ||= []) << str
      return
    end
    # Focus-gate: only the FOCUSED agent's frames paint. A non-focused agent
    # (the main loop while attached to a sub, or a sub while at main) keeps
    # running and recording its session but must not paint the screen the
    # focused agent owns. DROP the frame (do NOT park — a focus switch
    # replays the newly-focused agent's full session, so a parked line would
    # duplicate it). The attach/detach REPLAY is exempt (@replaying).
    return if origin != @focused_agent_id && !@replaying

    @partial = +""
    render_frame(committed: str)
  end
end

#quit_pending?Boolean

True once the reader has seen an EOF/quit (empty-buffer Ctrl+D or a closed stdin). The idle poll loop checks this alongside its Ctrl+C flag so a single Ctrl+D at the empty idle prompt returns nil (EOF) and the REPL’s quit-guard runs — instead of spinning forever (the reader thread has already stopped). Observed once, then cleared by #clear_quit_pending.

Returns:

  • (Boolean)


1151
# File 'lib/rubino/ui/bottom_composer.rb', line 1151

def quit_pending? = @quit_pending

#real_io_input?Boolean

True when @input is a real IO whose #wait_readable(0) can poll the queue without blocking — i.e. it exposes an integer fileno. A StringIO answers #fileno but raises NotImplementedError, so it falls to the plain #getc drain instead (its #getc is non-blocking and nil-terminated).

Returns:

  • (Boolean)


781
782
783
784
785
# File 'lib/rubino/ui/bottom_composer.rb', line 781

def real_io_input?
  @input.fileno.is_a?(Integer)
rescue StandardError
  false
end

#repaint_after_takeoverObject

Run the resume-repaint hook (set at #request_takeover time) once the composer has fully left takeover mode, so the SUBAGENT CARD block — and its aggregated ‘⛔N subagents waiting on you` last row — is repainted from the live registry. Run OUTSIDE the @render lock (the hook calls #set_cards, which re-takes @render) and only when the composer settled back un-suspended; cleared each time so it never fires for a later, hook- less takeover. Best-effort: a cosmetic repaint must never wedge the turn.



691
692
693
694
695
696
697
698
699
700
# File 'lib/rubino/ui/bottom_composer.rb', line 691

def repaint_after_takeover
  hook = nil
  @render.synchronize do
    hook = @on_takeover_resume
    @on_takeover_resume = nil
  end
  hook&.call unless @suspended
rescue StandardError
  nil
end

#request_takeover(on_resume: nil, &block) ⇒ Object

MID-TURN AUTO-OPEN (Option A) — request that block runs as a takeover on the INPUT thread, by itself, while the parent turn keeps streaming. Called from ANOTHER thread (the child that just blocked on ask_parent): we record the block under @render — atomically against the keystroke handler’s buffer edits — SNAPSHOT the in-progress draft + cursor right there (so a keystroke in flight can’t tear it), and signal the wake self-pipe. The reader’s IO.select returns, sees the pending takeover, breaks its raw loop and runs #run_pending_takeover ON ITS OWN thread. No-op (returns false) when no composer is reading (not running / already suspended / no wake pipe) — the idle poll covers the not-in-a-turn case.

ONE takeover at a time: a second request while one is pending/running is dropped here (the FIFO re-read after delivery picks up the newcomer), so the snapshot is never overwritten mid-takeover.



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/rubino/ui/bottom_composer.rb', line 582

def request_takeover(on_resume: nil, &block) # rubocop:disable Naming/PredicateMethod -- queues a takeover and reports whether it was accepted, not a pure query
  return false unless @running && !@suspended && @wake_pipe

  @render.synchronize do
    # ONE dropdown loop at a time (#486). Reject when a takeover is already
    # QUEUED (@pending_takeover) OR currently RUNNING (@takeover_active) —
    # the latter closes the gap between #run_pending_takeover clearing
    # @pending_takeover and the dropdown suspending the composer (where a
    # 2nd near-simultaneous ask would otherwise slip past, arm a SECOND
    # pending takeover, and spawn an overlapping #answer_all_human loop with
    # duplicated dropdown frames + a stale "still waiting" id). The dropped
    # ask is not lost: #answer_all_human's FIFO re-read of awaiting_human
    # surfaces it the instant the first loop resolves the current head.
    return false if @pending_takeover || @takeover_active

    @pending_takeover   = block
    @takeover_snapshot  = [buffer.dup, cursor]
    # Repaint hook run once the dropdown closes and the composer has resumed
    # — see @on_takeover_resume. The cards (with the ⛔N hint) are wiped on
    # suspend; this makes them come back from the live registry on resume.
    @on_takeover_resume = on_resume
  end
  begin
    @wake_pipe.write("x")
  rescue Errno::EPIPE, IOError
    # The reader already tore down between our guard and the signal; clear
    # the pending state so it can't leak into the next reader.
    @render.synchronize { clear_pending_takeover }
    return false
  end
  true
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).



1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
# File 'lib/rubino/ui/bottom_composer.rb', line 1586

def resize
  @render.synchronize do
    old_cols = @cols
    @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!
    # The terminal reflows the bottom rows itself on a resize, so the
    # geometry is deliberately forgotten (#401). Sync @input_cols to the
    # new width too so the redraw below does NOT re-arm the keystroke-path
    # reflow clear (#481) against geometry we just zeroed — that would
    # over-clear and re-introduce the #401 stacking.
    @input_cols = @cols
    # CHEAP-PATH resize repaint (no live region above the prompt — the raw
    # #503 repro: typing a wrapping line and dragging the window narrower).
    # reset_geometry! zeroed @input_above, so the cheap draw_input below
    # would clear ZERO rows above the caret — but the terminal has already
    # REFLOWED the prior-width input block onto a DIFFERENT (usually taller)
    # physical footprint, whose rows ABOVE the new caret survive as stale
    # "❯" rows. A SECOND consecutive SIGWINCH (120→50→40) compounds it: the
    # 50-col frame's own under-clear strands a 120-col row that neither the
    # 50- nor the 40-col count reaches (#503). Re-arm the clear to the
    # WORST-CASE above-caret footprint the block has occupied across the
    # whole resize chain — the old-width reflow plus the carried high-water
    # (#497) — so clear_input_block walks UP over every reflowed row before
    # the fresh redraw. This is BOUNDED by the block's own row span
    # (rows_above_caret_at caps at @max_input_rows - 1), so it never marches
    # into committed scrollback the way the OLD geometry walk did (#401):
    # the walk clears only the reflowed copy of THIS block, then one clean
    # frame is drawn. The full-frame path (live_region?) is untouched —
    # render_frame's #clear already erases the whole region (#401).
    unless live_region?
      @input_above_high_water = [
        @input_above_high_water,
        rows_above_caret_at(row_budget_for(old_cols)),
        rows_above_caret_at(row_budget_for(@cols))
      ].max
      @region.widen_input_above(@input_above_high_water)
    end
    # 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

#restore_draft_snapshotObject

Restores the buffer + cursor captured at #request_takeover time (and COMPLETED by #drain_inflight_into_draft), byte for byte, under @render —so the dropdown’s keystrokes never touched the draft and the human’s caret returns exactly where it was. A no-op when nothing was snapshotted.



791
792
793
794
795
796
797
798
799
800
# File 'lib/rubino/ui/bottom_composer.rb', line 791

def restore_draft_snapshot
  @render.synchronize do
    snap = @takeover_snapshot
    @takeover_snapshot = nil
    next unless snap

    buf, cur = snap
    @input_line.replace(buf.to_s).move_to(cur.to_i)
  end
end

#resumeObject

RESUME after #suspend: restore the StdoutProxy, re-enter raw mode, restart the reader, and redraw the input line from the preserved buffer.



508
509
510
511
512
513
514
515
516
517
# File 'lib/rubino/ui/bottom_composer.rb', line 508

def resume
  return if @in_takeover # paired with #suspend: the takeover restores on its own
  return unless @suspended

  leave_takeover_mode
  @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.



1409
1410
1411
# File 'lib/rubino/ui/bottom_composer.rb', line 1409

def row_budget
  [@cols - 1, @prefix_width + 1].max
end

#row_budget_for(cols) ⇒ Object

The per-row display-column budget for an ARBITRARY width, mirroring #row_budget (which reads @cols) without disturbing @cols — used to count the on-screen block’s reflowed rows at a width other than the live one.



1289
1290
1291
# File 'lib/rubino/ui/bottom_composer.rb', line 1289

def row_budget_for(cols)
  [cols - 1, @prefix_width + 1].max
end

#rows_above_caret_at(budget) ⇒ Object

The number of visual rows ABOVE the caret row when buffer is wrapped at the given per-row budget, mirroring #layout_input / #caret_position’s wrap math without rebuilding the rows (so it can cost-cheaply answer “how many physical rows does this block occupy at width X” for the reflow clear, #481). Continuation rows hang at @prefix_width like the real layout. Capped at @max_input_rows - 1, since the printed block is windowed to @max_input_rows and the clear walks only the printed rows.



1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
# File 'lib/rubino/ui/bottom_composer.rb', line 1300

def rows_above_caret_at(budget)
  row = 0
  caret_row = 0
  width = @prefix_width
  buffer.each_char.with_index do |ch, i|
    caret_row = row if i == cursor # the row the caret's char sits on
    if ch == "\n"
      row += 1
      width = @prefix_width
      next
    end
    w = display_width(ch)
    if width + w > budget
      row += 1
      width = @prefix_width
    end
    caret_row = row if i == cursor # re-resolve after a wrap on this char
    width += w
  end
  caret_row = row if cursor >= buffer.length # caret at end of buffer
  [caret_row, @max_input_rows - 1].min
end

#run_pending_takeoverObject

Runs the queued mid-turn takeover ON the reader thread, between raw sessions (the prior ‘@input.raw` block has already left cooked mode). The draft was SNAPSHOTTED at request time; here we first COMPLETE that snapshot — keystrokes the human typed but the dying raw session never getc’d are still sitting in the kernel TTY queue, so we drain them THROUGH the normal key handler into buffer and re-snapshot (see #drain_inflight_into_draft) BEFORE the dropdown starts reading $stdin. Without that, those in-flight bytes leak into TTY::Prompt’s filter field and the restored draft is short. Then we enter takeover terminal mode (restore real $stdout, clear prompt rows — the reader is NOT stopped, it IS us), run the dropdown block (it reads the real $stdin and delivers the answer down the child’s gate), then RESTORE the exact draft + cursor and leave takeover mode (flush parked stream lines, redraw the prompt). The caller’s outer loop then re-enters a fresh raw session, so the human continues typing the preserved draft seamlessly. Every failure path still restores terminal state + draft so raw mode never leaks past the dropdown.



639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
# File 'lib/rubino/ui/bottom_composer.rb', line 639

def run_pending_takeover
  block = nil
  @render.synchronize do
    block = @pending_takeover
    @pending_takeover = nil
    # Mark the takeover RUNNING the instant we adopt the block, BEFORE the
    # drain/suspend, so a 2nd ask arriving in the gap before #suspend flips
    # @suspended is rejected by #request_takeover's guard (#486 — one
    # dropdown loop at a time; the FIFO re-read surfaces it after).
    @takeover_active = true if block
  end
  return unless block

  drain_inflight_into_draft
  enter_takeover_mode
  # The dropdown's @ui.select/@ui.ask nest BottomComposer.run_in_terminal,
  # whose ensure would otherwise #suspend/#resume THIS composer and spawn a
  # second reader mid-takeover (the one-shot-per-session corruption). The
  # reader-thread takeover already owns the lifecycle, so neuter that nested
  # suspend/resume for the duration of the block (cleared in the ensure,
  # before our own leave_takeover_mode restores the terminal).
  @in_takeover = true
  begin
    # RESIDUAL B: catch keystrokes that accrued during the suspend
    # transition (after the request-time drain) before the picker grabs
    # $stdin, so they land in the draft, not the picker's filter.
    final_drain_into_draft
    block.call
  rescue StandardError
    # A dropdown hiccup must never leave the terminal wedged or lose the
    # draft — fall through to the restore in the ensure.
    nil
  ensure
    @in_takeover = false
    restore_draft_snapshot
    leave_takeover_mode
    repaint_after_takeover
    # Release the one-at-a-time guard only after the dropdown loop has fully
    # resolved and the composer has resumed — so the NEXT pending ask (a
    # sibling that blocked while this loop ran) is taken cleanly on the
    # reader's next session rather than overlapping this one (#486).
    @render.synchronize { @takeover_active = false }
  end
end

#set_cards(lines, origin: :main) ⇒ 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.



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
946
947
948
949
950
951
952
# File 'lib/rubino/ui/bottom_composer.rb', line 918

def set_cards(lines, origin: :main)
  # 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
  # Focus-gate: the subagent-card stack belongs to the MAIN view; don't
  # repaint it over a focused sub. Drop the frame when not focused. (The
  # gate read is duplicated below under @render for the actual paint; this
  # early return spares the Array#first when we know we'll drop it.)
  return if origin != @focused_agent_id && !@replaying

  capped = Array(lines).first(MAX_CARD_ROWS)
  @render.synchronize do
    # Re-check the focus gate under @render: the early return above can race
    # a focus switch between its read and this block, so the authoritative
    # drop happens here, where the focus read and the paint are atomic.
    return if origin != @focused_agent_id && !@replaying
    # COALESCE: a card repaint that would draw the EXACT same rows is a
    # no-op. The idle ticker (1 Hz) and every child tool-start/finish poke
    # a repaint, but most carry no visible change (same cards, same
    # elapsed bucket); re-running #render_frame for them only re-issues the
    # clear→redraw cursor walk over the live region, which on a real
    # terminal races the raw input reader and could drop/garble an
    # in-flight keystroke or wedge submit (#485). Repaint ONLY when the
    # rows actually changed, so an unchanged registry tick never disturbs
    # the composer buffer/cursor/input reader. (A real CHANGE still
    # repaints, under this same mutex, so cards stay live.)
    return if capped == @cards

    @cards = capped
    render_frame(committed: nil)
  end
end

#set_partial(str, origin: :main) ⇒ 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.



869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
# File 'lib/rubino/ui/bottom_composer.rb', line 869

def set_partial(str, origin: :main)
  # 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
    # Focus-gate: a non-focused agent's live tail AND status spinner
    # (paint_live → set_partial) must NOT animate over the focused agent's
    # view. Drop the frame; the replay path is exempt (@replaying). Checked
    # under @render so the focus read and the paint can't straddle a switch.
    return if origin != @focused_agent_id && !@replaying

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



1065
1066
1067
1068
1069
1070
1071
1072
# File 'lib/rubino/ui/bottom_composer.rb', line 1065

def set_status(text)
  return if @suspended

  @render.synchronize do
    @status = (text || "").to_s
    redraw
  end
end

#set_turn_status(str, origin: :main) ⇒ Object

Sets the live TURN activity shown in the FOOTER (#status_row) — the animated facet “◆ writing · 47s · …” produced by the CLI status ticker. Mirrors #set_partial’s discipline EXACTLY (same suspend / focus-gate guards and @render-synchronized redraw) so the footer can’t animate over an attached sub’s view. An empty string clears it; the footer then reverts to the plain model/ctx bar on the next frame.



894
895
896
897
898
899
900
901
902
903
# File 'lib/rubino/ui/bottom_composer.rb', line 894

def set_turn_status(str, origin: :main)
  return if @suspended

  @render.synchronize do
    return if origin != @focused_agent_id && !@replaying

    @turn_status = (str || "").to_s
    render_frame(committed: nil)
  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.



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/rubino/ui/bottom_composer.rb', line 452

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.



1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
# File 'lib/rubino/ui/bottom_composer.rb', line 1473

def status_row
  return nil if @cols < MIN_STATUS_COLS

  # The live turn activity ("◆ writing · …") prepended to the model/ctx bar
  # so a turn shows ONE footer, not a separate activity row above the prompt.
  active = !@turn_status.empty?
  base   = active ? "#{@turn_status}  #{@status}".strip : @status
  hint   = (@turn_active || @content_streaming) && @on_interrupt ? interrupt_hint : nil

  # Candidates richest-first; render the first that fits the row. On
  # overflow we shed the least-important pieces in order — drop the cosmetic
  # hint, then the model/ctx tail (keep the live turn info, which changes
  # every frame) — rather than truncating mid-ANSI or showing nothing.
  candidates = [hint && join(base, hint), base]
  candidates += [hint && join(@turn_status, hint), @turn_status] if active
  candidates.compact.reject(&:empty?).find { |row| fits?(row) }
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.



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/rubino/ui/bottom_composer.rb', line 473

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)


976
977
978
# File 'lib/rubino/ui/bottom_composer.rb', line 976

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



496
497
498
499
500
501
502
503
504
# File 'lib/rubino/ui/bottom_composer.rb', line 496

def suspend
  return if @in_takeover # the reader-thread takeover already owns the lifecycle
  return unless @running && !@suspended

  stop_reader
  enter_takeover_mode
rescue IOError, Errno::ENOTTY, Errno::EIO
  nil
end

#suspended?Boolean

True while the composer has yielded the screen (a takeover dropdown or a run_in_terminal block owns $stdin/$stdout). The auto-open trigger reads this to bail when the idle resolver is already mid-surface (#513), so only one path claims the shared composer.

Returns:

  • (Boolean)


1345
# File 'lib/rubino/ui/bottom_composer.rb', line 1345

def suspended? = @suspended

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



1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
# File 'lib/rubino/ui/bottom_composer.rb', line 1418

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

#with_replay_exemptObject

Run block with the main-render gate EXEMPTED, so the attach/detach REPLAY (the focused view the user is meant to see) renders even while main-render is suppressed. The reader thread drives both the replay and the attach itself, so this is never re-entered from two threads; the brief window in which a background parent-turn frame could also slip through is harmless — detach repaints main from the full session replay regardless.



1101
1102
1103
1104
1105
1106
1107
# File 'lib/rubino/ui/bottom_composer.rb', line 1101

def with_replay_exempt
  prev = @replaying
  @replaying = true
  yield
ensure
  @replaying = prev
end