Class: Rubino::UI::BottomComposer
- Inherits:
-
Object
- Object
- Rubino::UI::BottomComposer
- Defined in:
- lib/rubino/ui/bottom_composer.rb
Overview
A persistent, VISIBLE, editable input line pinned at the bottom of the terminal while agent output streams ABOVE it and scrolls into native scrollback. No alternate screen, no mouse tracking — trackpad/wheel scroll and text selection keep working like a normal shell.
This is the Ruby equivalent of prompt_toolkit’s patch_stdout / run_in_terminal: every write that should land above the prompt goes through #print_above, which erases the input line, emits the output (it scrolls up), then redraws the input from the preserved buffer. A render Mutex makes each erase→print→redraw an atomic frame so the streaming writer and the keystroke handler never interleave a half-frame.
Responsibilities:
* own the editable +buffer+ and draw it ({#draw_input})
* funnel all turn output through {#print_above} so it never clobbers the
input line (the {StdoutProxy} swaps +$stdout+ for the turn so the ~30
existing +$stdout.print/puts+ call sites need zero changes)
* run a raw, char-by-char keystroke loop in a thread that echoes typed
chars and pushes completed lines into the shared
{Interaction::InputQueue} the steering logic already consumes
Four collaborators carry the cohesive sub-jobs behind narrow seams, with the composer as the facade that owns the render mutex and the public API: EscapeReader (escape-sequence byte reading/parsing → semantic actions), CompletionMenu (the /command + @file dropdown state machine + rows), QueuedIndicators (the “⏳ queued:” stack + rows) and LiveRegion (the erase→commit→redraw frame discipline + width math). StatusBar formats the model/context line the composer pins BELOW the input (see below).
The INPUT BLOCK is multi-row: a buffer longer than the terminal width WRAPS and the input grows downward as the user types (like Claude Code), up to max_input_rows visual rows; past the cap it scrolls vertically, keeping the caret row in view. A multi-line PASTE keeps its REAL newlines in the buffer and the submitted payload (#57) and each newline now renders as a REAL row break in the editing view. ↑/↓ move by visual row while the caret is inside a multi-row buffer and fall back to history navigation on the first/last row (the readline/Claude Code convention). Below the input block an optional dim STATUS BAR shows the model id + context saturation; it is the live region’s LAST row, redrawn with every frame and omitted on narrow (< MIN_STATUS_COLS) terminals.
(Two earlier MVP limitations no longer apply: arrows/Home/End/Delete/ word-jump now drive the cursor via #consume_escape_sequence, and the draw/wrap/clamp paths all measure by DISPLAY width — a wide CJK/emoji glyph counts as two columns — so fullwidth lines wrap at the right column instead of “slightly early”.)
Constant Summary collapse
- PROMPT =
"❯ "- ANSI_RE =
/\e\[[0-9;]*m/- MAX_CARD_ROWS =
Hard ceiling on the subagent card block (rows ABOVE the partial + prompt). The registry caps live children at MAX_CONCURRENT (3) and the formatter adds an overflow + hint line, so 5 rows covers the worst case while guaranteeing the live region can never grow unbounded and push the prompt off-screen — a corrupt caller is clamped, not trusted.
6- MAX_PARTIAL_ROWS =
Hard ceiling on the live partial rows so a runaway caller can never push the prompt off-screen (mirrors MAX_CARD_ROWS for the card block). 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_eschook (the Esc-Esc rewind picker —the Claude Code muscle-memory chord). Tight enough that a deliberate single Esc (menu dismiss) followed by an unrelated Esc later never reads as a chord. 0.4- PASTE_ON =
Bracketed paste (DEC 2004): the terminal wraps pasted text in ESC[200~ … ESC[201~ so we can tell a PASTE from typed keystrokes and keep each embedded n from submitting a half-line (L1 — “pasteline2” glue). The body is inserted as ONE editable string with its REAL newlines preserved (#57, see #submit_paste); each renders as a real row break in the multi-row input block. We enable it on start, disable on stop/suspend; the EscapeReader accumulates the body between the markers.
"\e[?2004h"- PASTE_OFF =
"\e[?2004l"
Class Attribute Summary collapse
-
.current ⇒ Object
Returns the value of attribute current.
Instance Attribute Summary collapse
-
#cards ⇒ Object
readonly
The card rows currently shown (test/inspection helper).
-
#focused_agent_id ⇒ Object
readonly
The agent currently allowed to paint (the focused view).
-
#output ⇒ Object
readonly
The REAL terminal IO captured before the StdoutProxy swap.
Class Method Summary collapse
-
.active?(input: $stdin, output: $stdout) ⇒ Boolean
True only when both ends are real TTYs.
-
.run_in_terminal ⇒ Object
Run
blockwith the REAL terminal restored — the Ruby equivalent of prompt_toolkit’srun_in_terminal. -
.run_in_terminal_with_pending(consume_queue: true) ⇒ Object
Like BottomComposer.run_in_terminal, but FIRST reconciles the mid-turn type-ahead queue with the prompt about to open (BUG 01): once the composer is suspended (its reader thread stopped, @input back in cooked mode), it drains the in-flight keystrokes + (when
consume_queue) the oldest parked queue line and YIELDS that pending answer string to the block, which uses it to PREFILL the prompt.
Instance Method Summary collapse
- #agent_menu_open? ⇒ Boolean
-
#announce(text) ⇒ Object
Sets the TRANSIENT announcement row (the Shift+Tab mode confirmation).
-
#announce_pending(text) ⇒ Object
TRAP-SAFE announce for the during-turn Ctrl+C double-tap hint (#426).
-
#begin_content_stream ⇒ Object
Marks the start of an ACTIVE content stream (called by the CLI when the first answer token arrives).
-
#begin_turn ⇒ Object
Marks the START of a turn — the chat loop’s run_turn calls this when it hands a prompt to the runner.
-
#buffer ⇒ Object
The current editable text (test/inspection helper + the draft accessor chat_command reads).
- #build_menus(completion_source) ⇒ Object
-
#caret_position(rows) ⇒ Object
The caret’s [visual_row, display_col] within a layout.
-
#clear_quit_pending ⇒ Object
Clears the EOF/quit flag (the idle loop consumes it once it has acted on the EOF).
-
#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). -
#consume_queued_line ⇒ Object
Pop the OLDEST line off the type-ahead queue (FIFO, same as #next_input) and clear its “⏳ queued:” indicator so it visibly moves off the pending-rows into the open prompt.
-
#drain_inflight_bytes ⇒ Object
Drain the raw bytes ALREADY queued on @input (the kernel TTY buffer) into a plain string, WITHOUT routing them through #handle_key — so a buffered newline can’t trip #submit_line (which would push the half-typed line back into @input_queue) and the bytes never reach TTY::Prompt’s filter.
-
#draw_input ⇒ Object
Redraws the INPUT BLOCK — the wrapped buffer rows plus the status bar — and parks the terminal cursor at the insertion point (cursor).
-
#end_content_stream ⇒ Object
Marks the end of the content stream (CLI stream_end / finalize).
-
#end_turn ⇒ Object
Marks the END of a turn — the chat loop’s run_turn ‘ensure` calls this AFTER the runner has fully unwound (so the turn-summary footer is already in scrollback).
-
#finalize_region ⇒ Object
Row-accurately ERASE the whole live region in place and reset its on-screen geometry to a clean blank top row — used by the stream FINALIZE / INTERRUPT / force-summary paths right before they commit their last line (#421).
-
#fits?(str) ⇒ Boolean
True when
str‘s visible width fits the status row (one column of slack). -
#flush_parked_writes ⇒ Object
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.
-
#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.
-
#handle_key(ch) ⇒ Object
Feeds a single character through the edit logic.
-
#idle_interrupt(window: 2.0) ⇒ Object
Handle a Ctrl+C pressed at the IDLE prompt (BH-2).
-
#init_session_state(attached: false) ⇒ Object
Suspend/write-park + focus-gating state, factored out of #initialize.
-
#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
constructor
A new instance of BottomComposer.
-
#interrupt_hint ⇒ Object
The dim “(esc to interrupt)” type-ahead affordance shown in the status row while a turn is active (#421).
-
#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).
-
#layout_input ⇒ Object
Lays out buffer into wrapped VISUAL rows at the current width.
- #main_render_suppressed? ⇒ Boolean
-
#menu_open? ⇒ Boolean
True when the /command + @file completion menu is open (inspection helper; the reader/specs check it to branch Tab/Enter/Esc handling).
-
#park_caret(rows, caret_col, below) ⇒ Object
Park the terminal cursor at the caret after the block is fully printed (relative moves are only safe once nothing else will scroll): walk up past the rows below the caret row, re-home, and step right to the caret column.
- #pastel ⇒ Object
-
#prefill(text) ⇒ Object
Replaces the editable buffer with
text— MULTILINE-SAFE: real newlines stay in the buffer and render as real row breaks, exactly like a bracketed paste — parking the caret at the end, ready to edit. -
#print_above(str, origin: :main) ⇒ Object
Commits one block of output ABOVE the input line — it scrolls up into native scrollback — then redraws the prompt.
-
#quit_pending? ⇒ Boolean
True once the reader has seen an EOF/quit (empty-buffer Ctrl+D or a closed stdin).
-
#real_io_input? ⇒ Boolean
True when @input is a real IO whose #wait_readable(0) can poll the queue without blocking — i.e.
-
#reconfigure(prompt: nil, echo: :queued, on_ctrl_o: nil, on_mode_cycle: nil, on_agent_cycle: nil, on_interrupt: nil, on_double_esc: nil, on_idle_interrupt: nil, on_escape: nil, on_back: nil, on_busy_command: nil, status_line: nil, attached: false) ⇒ Object
Re-point the PER-PHASE configuration on a single long-lived composer (BUG 02).
-
#reset_input ⇒ Object
Empty the editable buffer + close any open menu, without the history reset or the eager redraw #prefill does (BUG 02).
-
#resize ⇒ Object
Recomputes width from the terminal and redraws under the mutex.
-
#resume ⇒ Object
RESUME after #suspend: restore the StdoutProxy, re-arm the WINCH/CONT traps, FLUSH any stream lines parked while suspended (R1 write-park) so they land in scrollback in order, redraw the input line from the preserved buffer, then restart the reader (which re-enters raw mode).
-
#row_budget ⇒ Object
The display columns available per input row: one short of the width so a glyph in the final column never arms the terminal’s deferred auto-wrap (the same rule LiveRegion#emit_row applies).
-
#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.
-
#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). -
#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).
-
#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).
-
#set_status(text) ⇒ Object
Updates the status bar pinned below the input (model + context saturation — see StatusBar) and repaints in place.
-
#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.
-
#start ⇒ Object
Starts the keystroke reader thread and draws the initial prompt.
-
#status_row ⇒ Object
The status-bar row for this frame, or nil when there is no bar: the status text is empty, the terminal is too narrow to be useful, or the styled line wouldn’t fit the row (omit whole rather than truncate mid-ANSI — a cut escape sequence would leak attributes into the terminal).
-
#stop ⇒ Object
Stops the reader thread, restores cooked mode, and leaves the cursor on a fresh line so the next REPL prompt isn’t glued to the input line.
-
#streaming? ⇒ Boolean
True while the model’s ANSWER content is actively streaming.
-
#suspend ⇒ Object
PAUSE the composer so an interactive prompt can own the real terminal (see BottomComposer.run_in_terminal).
-
#take_pending_for_prompt(consume_queue: true) ⇒ Object
MAIN-AGENT MID-TURN PROMPT (BUG 01) — reconcile the two uncoordinated mid-turn input sinks at the confirm/ask ↔ composer seam.
-
#visible_input_rows ⇒ Object
The PRINTED input rows for this frame plus the caret position within them: the layout, windowed to @max_input_rows when the buffer outgrows the cap (the window follows the caret row minimally, like a scrolling viewport), each row rendered to its final string (prompt prefix + token highlight on a single-row buffer; plain continuation rows).
-
#with_replay_exempt ⇒ Object
Run
blockwith 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.
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.
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 = (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, synchronized: synchronized_output?(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_session_state(attached: attached) @cols = compute_cols end |
Class Attribute Details
.current ⇒ Object
Returns the value of attribute current.
391 392 393 |
# File 'lib/rubino/ui/bottom_composer.rb', line 391 def current @current end |
Instance Attribute Details
#cards ⇒ Object (readonly)
The card rows currently shown (test/inspection helper).
1049 1050 1051 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1049 def cards @cards end |
#focused_agent_id ⇒ Object (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.
947 948 949 |
# File 'lib/rubino/ui/bottom_composer.rb', line 947 def focused_agent_id @focused_agent_id end |
#output ⇒ Object (readonly)
The REAL terminal IO captured before the StdoutProxy swap. UI::Notifier rings the attention bell here while a turn owns the screen — BEL never moves the cursor, so it can’t disturb the pinned input block.
1054 1055 1056 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1054 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.
380 381 382 383 384 |
# File 'lib/rubino/ui/bottom_composer.rb', line 380 def self.active?(input: $stdin, output: $stdout) input.tty? && output.tty? rescue StandardError false end |
.run_in_terminal ⇒ Object
Run block with the REAL terminal restored — the Ruby equivalent of prompt_toolkit’s run_in_terminal. When a composer owns the screen for the current turn, PAUSE it (stop the raw reader, restore $stdout to the real IO, leave cooked mode, clear the prompt rows) for the duration of the block, then RESUME it (re-enter raw mode, restart the reader, redraw the preserved buffer). With no active composer it just yields. This is what lets a mid-turn TTY::Prompt (approval / ask) read the real $stdin and let tty-screen probe the real $stdout’s size, instead of crashing on the write-only StdoutProxy or racing the reader thread for $stdin.
403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/rubino/ui/bottom_composer.rb', line 403 def self.run_in_terminal composer = current return yield unless composer composer.suspend begin yield ensure composer.resume end end |
.run_in_terminal_with_pending(consume_queue: true) ⇒ Object
Like run_in_terminal, but FIRST reconciles the mid-turn type-ahead queue with the prompt about to open (BUG 01): once the composer is suspended (its reader thread stopped, @input back in cooked mode), it drains the in-flight keystrokes + (when consume_queue) the oldest parked queue line and YIELDS that pending answer string to the block, which uses it to PREFILL the prompt. With no active composer it yields nil (nothing was parked — the prompt reads $stdin directly as before). Used by UI::CLI#ask / #confirm so a line a user types the instant an approval/clarification opens reaches THAT prompt instead of firing as a stray later turn (or leaking into the picker filter). See BottomComposer#take_pending_for_prompt.
425 426 427 428 429 430 431 432 433 434 435 436 |
# File 'lib/rubino/ui/bottom_composer.rb', line 425 def self.run_in_terminal_with_pending(consume_queue: true) composer = current return yield(nil) unless composer composer.suspend pending = composer.take_pending_for_prompt(consume_queue: consume_queue) begin yield(pending) ensure composer.resume end end |
Instance Method Details
#agent_menu_open? ⇒ Boolean
1066 1067 1068 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1066 def @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.
890 891 892 893 894 895 |
# File 'lib/rubino/ui/bottom_composer.rb', line 890 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.
911 912 913 |
# File 'lib/rubino/ui/bottom_composer.rb', line 911 def announce_pending(text) @announce = (text || "").to_s end |
#begin_content_stream ⇒ Object
Marks the start of an ACTIVE content stream (called by the CLI when the first answer token arrives). The thinking phase does NOT set this, so a footer/aside that commits during thinking still lands cleanly above.
796 797 798 |
# File 'lib/rubino/ui/bottom_composer.rb', line 796 def begin_content_stream @content_streaming = true end |
#begin_turn ⇒ Object
Marks the START of a turn — the chat loop’s run_turn calls this when it hands a prompt to the runner. From here through #end_turn the composer is “in a turn” (the THINKING phase AND the content stream), so a “queued ▸” type-ahead echo is deferred for the WHOLE turn, not only while content streams (D7e). Idempotent.
821 822 823 824 825 826 827 |
# File 'lib/rubino/ui/bottom_composer.rb', line 821 def begin_turn @turn_active = true # A fresh turn starts with no leftover streaming transients, and the # redraw paints the "(esc to interrupt)" affordance (#421). See # #reset_turn_transients. Idempotent. reset_turn_transients end |
#buffer ⇒ Object
The current editable text (test/inspection helper + the draft accessor chat_command reads). Delegates to the input-line model.
1212 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1212 def buffer = @input_line.text |
#build_menus(completion_source) ⇒ Object
1062 1063 1064 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1062 def (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.
1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1257 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_quit_pending ⇒ Object
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.
1012 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1012 def clear_quit_pending = (@quit_pending = false) |
#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.
777 778 779 780 781 782 783 784 |
# File 'lib/rubino/ui/bottom_composer.rb', line 777 def commit_queued(msg) removed = false @render.synchronize do removed = !@queued.remove(msg).nil? redraw if removed end removed end |
#consume_queued_line ⇒ Object
Pop the OLDEST line off the type-ahead queue (FIFO, same as #next_input) and clear its “⏳ queued:” indicator so it visibly moves off the pending-rows into the open prompt. Returns the line, or nil when none is parked.
581 582 583 584 585 586 587 |
# File 'lib/rubino/ui/bottom_composer.rb', line 581 def consume_queued_line line = @input_queue&.shift return nil unless line commit_queued(line) # drop its "⏳ queued:" row line end |
#drain_inflight_bytes ⇒ Object
Drain the raw bytes ALREADY queued on @input (the kernel TTY buffer) into a plain string, WITHOUT routing them through #handle_key — so a buffered newline can’t trip #submit_line (which would push the half-typed line back into @input_queue) and the bytes never reach TTY::Prompt’s filter. Bounded and non-blocking exactly like #drain_pending_input: gate each #getc on a zero-timeout #wait_readable for a real TTY (a StringIO #getc is already nil-terminated). Stops at the first CR/LF — that is the human submitting the prefill — and keeps only printable bytes (control bytes are dropped).
597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 |
# File 'lib/rubino/ui/bottom_composer.rb', line 597 def drain_inflight_bytes out = +"" selectable = real_io_input? loop do break if selectable && !@input.wait_readable(0) ch = @input.getc break if ch.nil? break if ["\r", "\n"].include?(ch) out << ch if ch =~ /[[:print:]]/ end out rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY out end |
#draw_input ⇒ Object
Redraws the INPUT BLOCK — the wrapped buffer rows plus the status bar —and parks the terminal cursor at the insertion point (cursor). The buffer WRAPS at the terminal width (a real newline forces a row break), growing the block downward up to @max_input_rows visual rows; past the cap a vertical window keeps the caret row in view. The block manages its own erase: the previous frame’s rows (recorded in the LiveRegion as input geometry) are cleared first, so a shrinking buffer never leaves stale rows, and the cheap keystroke path stays correct without a full live-region frame. All caret repositioning happens AFTER the last byte is printed, so a natural scroll while the block grows at the bottom of the screen can never desync the relative moves. Must be called under
1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1082 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_stream ⇒ Object
Marks the end of the content stream (CLI stream_end / finalize). Flushes the Ctrl+O reveal (‘┊` aside) deferred during the stream so it renders AFTER the finished answer block instead of between its chunks — the reveal belongs to the JUST-finished answer, so it lands right after the contiguous answer and BEFORE the turn-summary footer (D1). The “queued ▸” type-ahead echoes are NOT flushed here: they belong to the NEXT input the user lined up, so they flush at TURN END (#end_turn), after the footer, so the order reads answer → reveal → `↳ turn` footer → `queued ▸` echo(es) (D7a-c).
808 809 810 811 812 813 814 |
# File 'lib/rubino/ui/bottom_composer.rb', line 808 def end_content_stream @content_streaming = false return unless @deferred_reveal @deferred_reveal = false @on_ctrl_o&.call end |
#end_turn ⇒ Object
Marks the END of a turn — the chat loop’s run_turn ‘ensure` calls this AFTER the runner has fully unwound (so the turn-summary footer is already in scrollback). Idempotent. (The “queued ▸” deferred-echo flush that used to live here is retired: in the interrupt-by-default model a mid-turn Enter interrupts and runs next, and an explicit queue shows a live “⏳ queued:” indicator instead of a post-footer echo.)
835 836 837 838 839 840 |
# File 'lib/rubino/ui/bottom_composer.rb', line 835 def end_turn @turn_active = false # Wipe the per-turn transients so they can't bleed into the idle prompt # that follows (the redraw also clears the "(esc to interrupt)" row). reset_turn_transients end |
#finalize_region ⇒ Object
Row-accurately ERASE the whole live region in place and reset its on-screen geometry to a clean blank top row — used by the stream FINALIZE / INTERRUPT / force-summary paths right before they commit their last line (#421). The interrupt/force-summary repaints run after the status-row ticker and a flurry of intermediate transient frames (status_hide → clear_stream_region → status_stop, each a paint_live(“”)) have left the region’s recorded geometry out of step with the physical rows — the ticker paints a status row that #live_rows does NOT include, so @rows_above under-counts and the next #print_above’s relative e[1Ae[2K walk-up clears one row short: the live prompt is left on screen and gets COMMITTED into scrollback as the ghost ‘❯` above the `⎿ interrupted` marker, and the kept partial / whole summary block repaints a second time below it (the duplicated block). LiveRegion#clear walks UP exactly the rows it last painted and zeroes the counters, so the subsequent commit lands as ONE clean frame from a known-blank top row —the same geometry-reset discipline Ctrl+L (#395) / resize (#401) use, applied to the finalize path. Drops the partial too so a stale tail can’t repaint. A no-op-safe single frame: nothing is committed here, only the transient rows are erased and the prompt redrawn fresh.
678 679 680 681 682 683 684 |
# File 'lib/rubino/ui/bottom_composer.rb', line 678 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).
1368 1369 1370 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1368 def fits?(str) display_width(str.gsub(ANSI_RE, "")) <= @cols - 1 end |
#flush_parked_writes ⇒ Object
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.
527 528 529 530 531 532 533 534 |
# File 'lib/rubino/ui/bottom_composer.rb', line 527 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.
941 942 943 |
# File 'lib/rubino/ui/bottom_composer.rb', line 941 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.
1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 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 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1388 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 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.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).
981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 |
# File 'lib/rubino/ui/bottom_composer.rb', line 981 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_session_state(attached: false) ⇒ Object
Suspend/write-park + focus-gating state, factored out of #initialize.
329 330 331 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 |
# File 'lib/rubino/ui/bottom_composer.rb', line 329 def init_session_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 @parked_writes = 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 # 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, 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_hint ⇒ Object
The dim “(esc to interrupt)” type-ahead affordance shown in the status row while a turn is active (#421). Memoized — it never changes.
1374 1375 1376 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1374 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).
1360 1361 1362 1363 1364 1365 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1360 def join(left, right) return right if left.empty? return left if right.empty? "#{left} #{right}" end |
#layout_input ⇒ Object
Lays out buffer into wrapped VISUAL rows at the current width. Returns [rows, caret_row, caret_col] where each row is { chars:, start:, prompt: } — its codepoints, the buffer index of its first char, and whether it carries the prompt prefix (only the first) —and caret_row/caret_col locate the insertion point (col in DISPLAY columns from the screen’s left edge, so the caret column is comparable across rows for ↑/↓ navigation). A real “n” forces a row break; a char that would overflow the per-row budget wraps whole (wide glyphs are never split across rows). The caret is placed where the NEXT typed char will land.
Continuation rows (wrap or “n”) carry a HANGING INDENT of the prefix width (P12): every row’s text starts in the same column as the first row’s — after the rail + prompt — instead of dropping flush-left to column 0. The indent is pure layout (rail + spaces on render, width here) — never buffer content.
1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1230 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 |
#main_render_suppressed? ⇒ Boolean
949 |
# File 'lib/rubino/ui/bottom_composer.rb', line 949 def main_render_suppressed? = @focused_agent_id != :main |
#menu_open? ⇒ Boolean
True when the /command + @file completion menu is open (inspection helper; the reader/specs check it to branch Tab/Enter/Esc handling).
1058 1059 1060 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1058 def @menu.open? end |
#park_caret(rows, caret_col, below) ⇒ Object
Park the terminal cursor at the caret after the block is fully printed (relative moves are only safe once nothing else will scroll): walk up past the rows below the caret row, re-home, and step right to the caret column. Skipped entirely when printing already left the cursor there — the caret at the end of a frame’s last row, the common typing case — so those frames end with the buffer text, byte-minimal.
1202 1203 1204 1205 1206 1207 1208 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1202 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 |
#pastel ⇒ Object
1378 1379 1380 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1378 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.
1022 1023 1024 1025 1026 1027 1028 1029 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1022 def prefill(text) @render.synchronize do @menu.close! @input_line.replace(text.to_s) @history.reset! redraw end end |
#print_above(str, origin: :main) ⇒ Object
Commits one block of output ABOVE the input line — it scrolls up into native scrollback — then redraws the prompt. This is THE coordinator every finished above-the-prompt write goes through (StdoutProxy routes committed lines here). str may contain embedded newlines; each line is emitted with a trailing “rn” because OPOST is off in raw mode (a bare “n” would not return the carriage and the next line would stair-step). Any live streamed partial is cleared first so it doesn’t duplicate. A nil str just repaints the prompt; an EMPTY string commits one deliberate blank row (the P3 rhythm gaps — see LiveRegion#commit).
633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 |
# File 'lib/rubino/ui/bottom_composer.rb', line 633 def print_above(str, origin: :main) @render.synchronize do # R1 write-park: while SUSPENDED (an approval / clarification prompt # 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.
1007 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1007 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).
618 619 620 621 622 |
# File 'lib/rubino/ui/bottom_composer.rb', line 618 def real_io_input? @input.fileno.is_a?(Integer) rescue StandardError false end |
#reconfigure(prompt: nil, echo: :queued, on_ctrl_o: nil, on_mode_cycle: nil, on_agent_cycle: nil, on_interrupt: nil, on_double_esc: nil, on_idle_interrupt: nil, on_escape: nil, on_back: nil, on_busy_command: nil, status_line: nil, attached: false) ⇒ Object
Re-point the PER-PHASE configuration on a single long-lived composer (BUG 02). The REPL builds ONE composer per interactive session and #start / #stop it ONCE — the native machinery (reader thread, self/wake pipes, raw-mode entry, traps, the LiveRegion) is allocated once and torn down once, instead of churning a fresh BottomComposer per turn (the superlinear RSS growth). What DIFFERS between the idle prompt and an in-turn composer is only this config: the prompt string, the echo discipline and the key hooks. They are swapped here at each phase boundary (idle read ↔ run_turn) so the same instance behaves identically to the per-phase composers it replaced. Each keyword defaults to nil = “clear that hook for this phase” so a hook wired for the idle prompt (on_double_esc, on_idle_interrupt, on_escape) never leaks into a turn and vice-versa (on_interrupt, on_busy_command). The session-stable collaborators (input_queue, history, completion_source, paste_store, rail, the reader/pipes) are NOT touched —they were set at construction and stay put. Takes @render so a concurrent reader keystroke can’t observe a half-swapped hook set.
858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 |
# File 'lib/rubino/ui/bottom_composer.rb', line 858 def reconfigure(prompt: nil, echo: :queued, on_ctrl_o: nil, on_mode_cycle: nil, on_agent_cycle: nil, on_interrupt: nil, on_double_esc: nil, on_idle_interrupt: nil, on_escape: nil, on_back: nil, on_busy_command: nil, status_line: nil, attached: false) @render.synchronize do @prompt = prompt.to_s.empty? ? PROMPT : prompt @prompt_width = @prompt.gsub(ANSI_RE, "").length @prefix_width = @rail.gsub(ANSI_RE, "").length + @prompt_width @echo = echo @on_ctrl_o = on_ctrl_o @on_mode_cycle = on_mode_cycle @on_agent_cycle = on_agent_cycle @on_interrupt = on_interrupt @on_double_esc = on_double_esc @on_idle_interrupt = on_idle_interrupt @on_escape = on_escape @on_back = on_back @on_busy_command = on_busy_command @status = (status_line || "").to_s # Re-seed the focus-gate from the persistent attach-state, exactly as the # per-phase constructor used to (#82): the id (not a bool) so the # while-attached switcher marks the focused sub (#87). @focused_agent_id = attached || :main end self end |
#reset_input ⇒ Object
Empty the editable buffer + close any open menu, without the history reset or the eager redraw #prefill does (BUG 02). The REPL now REUSES one composer across turns, so an unsubmitted draft left in the buffer at turn end survives into the next idle read; the chat loop carries that draft explicitly (@pending_draft → #seed_draft), so the buffer must start EMPTY before the carried draft is re-seeded — otherwise it would double (“foo” + seeded “foo” ⇒ “foofoo”). On the OLD per-turn composer the fresh instance was already empty; this restores that baseline on the reused one. The follow-up #seed_draft (or the prompt’s own first frame) repaints, so no redraw here.
1041 1042 1043 1044 1045 1046 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1041 def reset_input @render.synchronize do @menu.close! @input_line.clear end end |
#resize ⇒ Object
Recomputes width from the terminal and redraws under the mutex. Public so the SIGWINCH handler (trap-context) and tests can call it.
Redraws the WHOLE live region (the in-progress streamed @partial AND the prompt), not just the prompt: on resize xterm reflows/clears the bottom rows, so repainting only the prompt left the live streaming line blank until the turn committed (X1). Repainting the partial at the new width keeps mid-stream output visible across a resize. Committed scrollback is untouched (the terminal reflows it natively).
1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1453 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 |
#resume ⇒ Object
RESUME after #suspend: restore the StdoutProxy, re-arm the WINCH/CONT traps, FLUSH any stream lines parked while suspended (R1 write-park) so they land in scrollback in order, redraw the input line from the preserved buffer, then restart the reader (which re-enters raw mode).
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 |
# File 'lib/rubino/ui/bottom_composer.rb', line 504 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) flush_parked_writes draw_input end @reader = start_reader self rescue IOError, Errno::ENOTTY, Errno::EIO nil end |
#row_budget ⇒ Object
The display columns available per input row: one short of the width so a glyph in the final column never arms the terminal’s deferred auto-wrap (the same rule LiveRegion#emit_row applies). Guarded so a degenerate narrow terminal still fits at least one char after the prompt instead of looping.
1276 1277 1278 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1276 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.
1162 1163 1164 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1162 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.
1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1173 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 |
#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.
736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 |
# File 'lib/rubino/ui/bottom_composer.rb', line 736 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.
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 |
# File 'lib/rubino/ui/bottom_composer.rb', line 691 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.
921 922 923 924 925 926 927 928 |
# File 'lib/rubino/ui/bottom_composer.rb', line 921 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.
716 717 718 719 720 721 722 723 724 725 |
# File 'lib/rubino/ui/bottom_composer.rb', line 716 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 |
#start ⇒ Object
Starts the keystroke reader thread and draws the initial prompt. Installs a SIGWINCH handler that recomputes the width and redraws under the mutex. Returns self.
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'lib/rubino/ui/bottom_composer.rb', line 441 def start return self if @running @running = true self.class.current = self install_winch_trap install_cont_trap @render.synchronize do # Leave a blank row above the first prompt so the first above-output # doesn't glue onto whatever the REPL just printed. @output.print(PASTE_ON) @output.print("\r\n") draw_input end @reader = start_reader self end |
#status_row ⇒ Object
The status-bar row for this frame, or nil when there is no bar: the status text is empty, the terminal is too narrow to be useful, or the styled line wouldn’t fit the row (omit whole rather than truncate mid-ANSI — a cut escape sequence would leak attributes into the terminal).
While a turn is active (thinking OR streaming) the row also carries the type-ahead AFFORDANCE — a dim “(esc to interrupt)” hint (#421) — so the user can see that Esc cancels the current turn (Enter now QUEUES). The hint is appended only when the styled status line is present and the combined plain width still fits; it never replaces the bar.
1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1340 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 |
#stop ⇒ Object
Stops the reader thread, restores cooked mode, and leaves the cursor on a fresh line so the next REPL prompt isn’t glued to the input line. Safe to call multiple times. Restores the previous SIGWINCH handler.
462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 |
# File 'lib/rubino/ui/bottom_composer.rb', line 462 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).
789 790 791 |
# File 'lib/rubino/ui/bottom_composer.rb', line 789 def streaming? @content_streaming end |
#suspend ⇒ Object
PAUSE the composer so an interactive prompt can own the real terminal (see run_in_terminal). Stops the raw reader and leaves cooked mode so TTY::Prompt can read $stdin uncontended, restores the REAL $stdout (the composer’s @output — built BEFORE the StdoutProxy swap) so tty-screen probes the real terminal, drops the WINCH/CONT traps, and clears the prompt rows. The typed buffer draft is preserved for #resume. Idempotent: a no-op once already suspended (or never started).
485 486 487 488 489 490 491 492 493 494 495 496 497 498 |
# File 'lib/rubino/ui/bottom_composer.rb', line 485 def suspend return unless @running && !@suspended stop_reader @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 } rescue IOError, Errno::ENOTTY, Errno::EIO nil end |
#take_pending_for_prompt(consume_queue: true) ⇒ Object
MAIN-AGENT MID-TURN PROMPT (BUG 01) — reconcile the two uncoordinated mid-turn input sinks at the confirm/ask ↔ composer seam. While a turn streams, the reader parks typed lines into @input_queue (the type-ahead queue) under a “⏳ queued:” indicator. When the SAME turn opens an interactive prompt (a tool-approval card or a ‘question`/clarification), that prompt was reading $stdin with NO knowledge of the queue — so a line parked the instant the prompt opened was invisible to it (it fired as a stray NEW turn afterwards), and in-flight keystrokes still queued on the kernel TTY leaked into TTY::Prompt’s filter field.
Called from UI::CLI#ask / #confirm AFTER the composer is suspended (the reader thread is stopped and @input is back in cooked mode, so we are the only reader of the kernel TTY queue) and BEFORE TTY::Prompt grabs $stdin. It:
1. DRAINS every byte ALREADY queued on the kernel TTY (the in-flight
keystrokes typed in the race window before/while the prompt opened)
so they can NOT leak into the picker's filter — bounded/non-blocking,
the same #wait_readable(0) gate #drain_pending_input uses. Bytes up to
the first CR/LF become the in-flight text; a CR/LF ends the drain (the
human "submitted" that prefill);
2. when +consume_queue+ (the freeform #ask / clarification path), POPS
the OLDEST line off @input_queue and clears its "⏳ queued:" indicator,
so it is delivered to THIS prompt instead of running as a later turn.
Returns the pending answer string (queued line, then any in-flight typed text appended) to PREFILL into the prompt — the human sees it and confirms/edits with Enter (never an auto-submit). Returns nil when nothing was pending. For the APPROVAL menu the caller passes consume_queue: false: the in-flight bytes are still drained (so they don’t reach the filter), the queued line is left in place (a destructive grant must not be auto-filled), and nil is returned.
568 569 570 571 572 573 574 575 |
# File 'lib/rubino/ui/bottom_composer.rb', line 568 def take_pending_for_prompt(consume_queue: true) inflight = drain_inflight_bytes queued = consume_queue ? consume_queued_line : nil parts = [queued, inflight].compact.reject(&:empty?) return nil if parts.empty? parts.join(queued && inflight && !inflight.empty? ? " " : "") end |
#visible_input_rows ⇒ Object
The PRINTED input rows for this frame plus the caret position within them: the layout, windowed to @max_input_rows when the buffer outgrows the cap (the window follows the caret row minimally, like a scrolling viewport), each row rendered to its final string (prompt prefix + token highlight on a single-row buffer; plain continuation rows).
1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1285 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_exempt ⇒ Object
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.
957 958 959 960 961 962 963 |
# File 'lib/rubino/ui/bottom_composer.rb', line 957 def with_replay_exempt prev = @replaying @replaying = true yield ensure @replaying = prev end |