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.
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_pending_takeover ⇒ Object
Drops the queued takeover + its draft snapshot.
-
#clear_quit_pending ⇒ Object
Clears the EOF/quit flag (the idle loop consumes it once it has acted on the EOF).
- #clear_turn_status ⇒ Object
-
#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). -
#drain_inflight_into_draft ⇒ Object
COMPLETE the request-time draft snapshot just before the dropdown opens, on the reader thread (the only thread allowed to
getc@input). -
#drain_pending_input ⇒ Object
Feed every byte ALREADY queued on @input through #handle_key, then stop — a bounded, non-blocking drain.
-
#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).
-
#enter_takeover_mode ⇒ Object
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.
-
#final_drain_into_draft ⇒ Object
RESIDUAL B: a FINAL non-blocking drain run AFTER #enter_takeover_mode and immediately BEFORE the dropdown block reads $stdin.
-
#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_takeover_state(attached: false) ⇒ Object
Mid-turn auto-open (Option A) + R1 write-park 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.
-
#leave_takeover_mode ⇒ Object
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.
- #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.
-
#partial? ⇒ Boolean
True when a live partial line is currently shown above the prompt.
- #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.
-
#repaint_after_takeover ⇒ Object
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.
-
#request_takeover(on_resume: nil, &block) ⇒ Object
MID-TURN AUTO-OPEN (Option A) — request that
blockruns as a takeover on the INPUT thread, by itself, while the parent turn keeps streaming. -
#resize ⇒ Object
Recomputes width from the terminal and redraws under the mutex.
-
#restore_draft_snapshot ⇒ Object
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.
-
#resume ⇒ Object
RESUME after #suspend: restore the StdoutProxy, re-enter raw mode, restart the reader, and redraw the input line from the preserved buffer.
-
#row_budget ⇒ Object
The display columns available per input row: one short of the width so a glyph in the final column never arms the terminal’s deferred auto-wrap (the same rule LiveRegion#emit_row applies).
-
#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). -
#run_pending_takeover ⇒ Object
Runs the queued mid-turn takeover ON the reader thread, between raw sessions (the prior ‘@input.raw` block has already left cooked mode).
-
#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).
-
#suspended? ⇒ Boolean
True while the composer has yielded the screen (a takeover dropdown or a run_in_terminal block owns $stdin/$stdout).
-
#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) # 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
.current ⇒ Object
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
#cards ⇒ Object (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_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.
1091 1092 1093 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1091 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.
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.
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_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.
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
1193 1194 1195 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1193 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.
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_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.
983 984 985 |
# File 'lib/rubino/ui/bottom_composer.rb', line 983 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.
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 |
#buffer ⇒ Object
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 (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_takeover ⇒ Object
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_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.
1156 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1156 def clear_quit_pending = (@quit_pending = false) |
#clear_turn_status ⇒ Object
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_draft ⇒ Object
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_input ⇒ Object
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_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
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_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).
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_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.)
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_mode ⇒ Object
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_draft ⇒ Object
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_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.
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).
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_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.
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 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).
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_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.
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_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.
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_mode ⇒ Object
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
1093 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1093 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).
1185 1186 1187 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1185 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.
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.
969 970 971 |
# File 'lib/rubino/ui/bottom_composer.rb', line 969 def partial? !@partial.empty? end |
#pastel ⇒ Object
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 |
#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).
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.
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).
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_takeover ⇒ Object
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 |
#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).
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_snapshot ⇒ Object
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 |
#resume ⇒ Object
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_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.
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_takeover ⇒ Object
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 |
#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.
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_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.
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 |
#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.
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).
976 977 978 |
# File 'lib/rubino/ui/bottom_composer.rb', line 976 def streaming? @content_streaming end |
#suspend ⇒ Object
PAUSE the composer so an interactive prompt can own the real terminal (see run_in_terminal). Stops the raw reader and leaves cooked mode so TTY::Prompt can read $stdin uncontended, restores the REAL $stdout (the composer’s @output — built BEFORE the StdoutProxy swap) so tty-screen probes the real terminal, and clears the prompt rows. The typed buffer draft is preserved for #resume. Idempotent: a no-op once already suspended (or never started).
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.
1345 |
# File 'lib/rubino/ui/bottom_composer.rb', line 1345 def suspended? = @suspended |
#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).
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_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.
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 |