Class: Rubino::UI::CLI
- Inherits:
-
PrinterBase
- Object
- Base
- PrinterBase
- Rubino::UI::CLI
- Defined in:
- lib/rubino/ui/cli.rb
Overview
Terminal-based UI adapter using TTY gems.
All output goes to stdout via plain prints — no alt-screen, no mouse capture, no cursor positioning. Native terminal scroll, copy, and shell history all keep working because we never leave the main screen.
Extends PrinterBase; uses compact append-only timeline rendering (no boxes, no per-element timestamps, no horizontal rules). Visual language:
● active tool or activity
✓ completed successfully
✗ failed
◆ approval required
┄ low-priority metadata
Constant Summary collapse
- PICKER_PAGE_SIZE =
Page size tty-prompt paginates a select menu at (its Paginator’s DEFAULT_PAGE_SIZE) — the count of menu rows visible at once, used to wipe a cancelled picker’s frame (#219).
6- MD_MARGIN =
The left margin every committed markdown line is printed behind. The live tail (#show_live_tail) reuses it so the raw in-flight lines sit in the SAME column as the rendered block they become — a flush-left tail under indented committed output read as a jarring seam.
" "- MIN_MARKDOWN_WIDTH =
Smallest usable markdown/table budget. Below this a streamed table’s columns collapse to ~1 char each (#95), so we floor here rather than at 1.
40- LIVE_TAIL_ROWS =
How many trailing lines of the in-flight block stay visible live (#127).
3- SPAWN_HANDLE_RE =
A spawn handle: the verbose model-facing acknowledgement the task tool returns for a BACKGROUND child. The model needs the whole instruction; the human only needs “it started”.
/\AStarted background subagent '([^']+)' as task (\S+?)\.(?:\s|\z)/- STATUS_TICK =
Repaint cadence for the status-row animation (seconds).
0.1- FACET_TRACK_CELLS =
“Ruby facet” skin: a red ◆ sweeping back and forth on a 5-cell dim ┄track (the house separator glyph). 12-frame loop @100ms — the facet dwells one extra beat at each end of the sweep.
5- FACET_FRAMES =
[0, 0, 0, 1, 2, 3, 4, 4, 4, 3, 2, 1].freeze
- INTERRUPT_HINT_AFTER =
Don’t nag fast turns: the “enter to interrupt” hint appears only after the wait has visibly dragged.
1.5- JOB_STATUS_LABELS =
Short human labels for the post-turn inline jobs the status row tracks.
{ "ExtractMemoryJob" => "memory", "DistillSkillJob" => "skills", "SummarizeSessionJob" => "summary" }.freeze
Instance Method Summary collapse
-
#activity_finished(name, metric: nil, failed: false) ⇒ Object
Activity finished.
-
#activity_started(name, hint: nil) ⇒ Object
Activity started: renders as ‘● name` or `● name hint` — a QUIET dim row with only the ● in cyan.
-
#answer_gap ⇒ Object
Exactly ONE blank line before the answer payload (P3) — skipped when the previous committed block already left a gap open.
-
#approval_requested(summary:, choices:) ⇒ Object
Approval requested: renders as ‘◆ summary`.
- #ask(prompt) ⇒ Object
-
#ask_timeout_hint ⇒ Object
The honest bound for the ⛔ banner: a blocking ask_parent waits at most tasks.ask_parent_timeout seconds, then the child proceeds with its best judgement (ask_parent_tool.rb).
-
#assistant_text(text) ⇒ Object
Markdown rendering: assistant output rendered as readable text with modest indentation, no box.
-
#body(text) ⇒ Object
Body text rendered with modest indentation (no big box).
- #box_close(*_pieces, color: nil) ⇒ Object
-
#box_open(*pieces, at: nil, color: nil) ⇒ Object
— Legacy box methods (used by print_session_history replay) —.
-
#branch_confirmation(new_id:, parent_id:, title:, included_probe:) ⇒ Object
Confirms a ‘/branch` fork in the dim block from the locked UX: the new session id + title, the parent it inherits from, and the literal way back (`/sessions <parent>`), bracketed by `┄ branched ┄` / `┄ now in <id> ┄` rails.
-
#cancellable_prompt ⇒ Object
A DEDICATED TTY::Prompt for cancellable pickers, with Esc bound to the same InputInterrupt Ctrl-C raises (#73): tty-reader parses full escape sequences, so arrows (ESC [ A…) never trip :keyescape — only a lone Esc does. Deliberately separate from the shared @prompt so the approval menu’s keymap is untouched (an Esc there must not become a deny)..
-
#clear_line ⇒ Object
In-place clear of the current row (CR + erase-line) before a committed line lands.
-
#commit_markdown_block(text) ⇒ Object
Renders a markdown string to committed, styled lines above the composer (each line as ‘$stdout.puts “#MD_MARGIN#line”`).
- #compression_finished(metadata, at: nil) ⇒ Object
- #compression_started(at: nil) ⇒ Object
-
#confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) ⇒ Boolean
Approval prompt with session memory.
-
#confirm_destructive(question) ⇒ Object
A destructive yes/No confirm — NOT the tool-approval menu (#218).
-
#denied(tool = nil) ⇒ Object
Explicit, visible confirmation that a denied command was NOT executed.
- #display_width(str) ⇒ Object
-
#elide_shown_reports(text) ⇒ Object
Drops the Result body from a completion notice whose report the lifecycle block ALREADY rendered in full (#subagent_lifecycle), so the user doesn’t read the same report twice — once at completion and again when the queued notice is injected next turn.
-
#erase_picker_frame(choice_count) ⇒ Object
Clears a cancelled picker’s drawn frame: 1 header row + the visible menu rows (tty-prompt paginates at PICKER_PAGE_SIZE).
-
#error(message) ⇒ Object
A turn that ends in ERROR must tear down the live “thinking…” animation (and any open stream) BEFORE the error line prints — otherwise the ticking row strands below the error and keeps interleaving into every subsequent print until a full repaint (#74).
-
#grid_overflows?(headers, rows) ⇒ Boolean
True when the natural grid width (column maxima + unicode borders + padding) won’t fit the terminal.
-
#hint_row(command, description) ⇒ Object
Welcome-panel hint row (P8): the actionable command is the ONE cyan accent; its description stays plain.
-
#initialize(session_id: nil, approval_cache: nil) ⇒ CLI
constructor
A new instance of CLI.
-
#input_injected(text) ⇒ Object
Confirms text the loop picked up mid-turn and injected into the CURRENT turn (Phase-2 steering).
-
#interactive_terminal? ⇒ Boolean
True when both ends are a real interactive terminal — the shared gate for every interactive prompt/menu (#ask / #select): off a TTY they return nil instead of rendering ANSI into a pipe.
- #job_enqueued(type) ⇒ Object
- #job_finished(type) ⇒ Object
-
#job_started(type) ⇒ Object
Post-turn inline jobs (P6): the aux-LLM memory extract / skill distill used to freeze the UI for seconds after the footer.
- #job_status_label(type) ⇒ Object
-
#markdown_width ⇒ Object
Column budget for markdown rendering: terminal width minus the MD_MARGIN indent applied to every committed line.
- #mode_changed(name, previous: nil) ⇒ Object
- #monotonic_now ⇒ Object
-
#note(text) ⇒ Object
Free-line annotation rendered as ‘┄ message ┄`, dim.
-
#notifier ⇒ Object
The attention notifier (terminal bell + optional command hook).
-
#paint_live(frame) ⇒ Object
Paints (or, with an empty
frame, clears) the ONE transient live row through whichever seam owns the bottom of the screen, resolved per call: * during a turn $stdout is the StdoutProxy — #live replaces the composer’s transient row under its render mutex; * an ACTIVE composer without the proxy is painted via BottomComposer#set_partial — same row, same mutex — NEVER with a raw CR repaint that would clobber the pinned prompt line (#169); * a bare TTY with no composer (the cooked /probe wait, #58; one-shot) repaints in place via CR + clear-line; * a pipe hosts nothing — raw escapes must not leak into the cooked output (#56). -
#panel_line(label, value, pointer: nil) ⇒ Object
Panel color diet (P8): dim label, PLAIN value, cyan reserved for the actionable pointer (‘(use /mcp)`).
-
#probe_aside(answer) ⇒ Object
Renders an ephemeral ‘probe` answer in the dim, fenced aside that the locked UX prescribes: an opening `┄ probe (ephemeral · not saved) ┄` rail, the answer body on a dim `┊` left-rail, then a closing `┄ vanished · main thread untouched ┄` rail.
-
#queued(text) ⇒ Object
Echoes a line the user typed mid-turn, parked for the next turn.
-
#reasoning_changed(mode, previous: nil) ⇒ Object
‘/reasoning <mode>`: confirm the session render-mode switch.
-
#reasoning_mode ⇒ Object
The active reasoning render mode (:hidden | :collapsed | :full), resolved from config (which /reasoning writes to, so the adapter gate and this render path share one source of truth).
-
#reasoning_status(mode) ⇒ Object
‘/reasoning` with no arg: confirm the current render mode in house style.
-
#redisplay_idle_prompt ⇒ Object
Ask Reline to repaint its prompt + current buffer after out-of-band output (the Ctrl+O reveal) has scrolled below the parked idle prompt.
-
#remember_reported_subagent(id) ⇒ Object
Bounded memory of lifecycle-rendered report ids (see #elide_shown_reports).
-
#render_cards(headers, rows) ⇒ Object
Vertical key/value cards: ‘Label value`, labels padded to a common width, a dim rule between records.
-
#render_markdown_block(text) ⇒ Object
A markdown string -> Array<String> of ANSI-styled lines (no indent).
-
#replay_user_input(text, at: nil) ⇒ Object
Replay user input in compact form.
-
#reveal_last_reasoning ⇒ Object
Ctrl+O reveal: re-render the LAST retained reasoning buffer as the full-style ‘┊` aside, committed into scrollback NOW (append-only — a scrollback terminal can’t un-print the committed cue, so this is a one-way reveal of the retained buffer, not a hide-toggle).
-
#select(prompt, choices) ⇒ Object
Arrow-key single-select menu — the SAME TTY::Prompt component the tool approval menu uses (see #approval_choice), so /sessions resume reuses the existing picker rather than introducing a second menu system (#145).
- #separator ⇒ Object
-
#session_scope_tip(tool, choice) ⇒ Object
One dim line, once per session, after the FIRST “Approve once” (#110): the “this tool (this session)” option already exists in the menu, but nothing surfaced it, so users approved every single edit by hand.
-
#set_subagent_cards ⇒ Object
Repaints the SUBAGENT CARD block in the live region from the BackgroundTasks registry (Variant A).
-
#stash_probe_draft(text) ⇒ Object
Holds text the user typed during a synchronous /probe wait (#221), so the next idle prompt seeds it back into ‘❯` — the wait owns a transient composer to echo input, but it’s torn down before the REPL reopens its idle composer, so the buffer is parked here in between.
-
#status_back_to_thinking ⇒ Object
After a tool’s ‘└ ✓` close row commits, swap the status row back to the thinking phase (the P4 inter-tool gap) with the accumulated stats.
-
#stream(chunk) ⇒ Object
— Streaming (unchanged except visual, now uses assistant_text) —.
-
#stream_block_end(_message_id = nil) ⇒ Object
Block boundary on the STREAMING path, driven by the adapter’s after_message callback (one assistant message == one content block; on a multi-step tool turn several blocks stream within one model call).
- #stream_end ⇒ Object
-
#subagent_ask_banner(id, subagent, question) ⇒ Object
Commits the ⛔ “a subagent needs you” attention banner into scrollback the instant a background child escalates an ask_parent to the human.
- #subagent_cards ⇒ Object
-
#subagent_finished(line, id: nil, status: "done", report: nil) ⇒ Object
A background subagent reached a terminal state.
-
#subagent_lifecycle(line, status: "done", report: nil, id: nil) ⇒ Object
ONE lifecycle grammar (P6): the live-card-shaped row (‘▸ sa_e488 · explore · completed · 1 tool · 12s`) — dim; red only on failure — and the child’s FULL report markdown-rendered under its own ‘↳ report:` lead (the #139 fold-in treatment), never amputated to a one-line head.
-
#suppress_interrupt_marker(value: true) ⇒ Object
One-shot suppression of the next ‘⎿ interrupted` marker (#111).
-
#table(headers:, rows:) ⇒ Object
Renders a table, degrading to a readable vertical card layout when the full grid would overflow a narrow terminal (#84).
-
#take_probe_draft ⇒ Object
Consumes the parked /probe draft (see #stash_probe_draft), or nil.
-
#terminal_cols ⇒ Object
Terminal column count, headless-safe (falls back to 80).
-
#think_changed(effort, previous: nil) ⇒ Object
‘/think <level>`: confirm the effort switch.
-
#think_status(effort) ⇒ Object
‘/think` with no arg: confirm the current effort in house style.
-
#thinking_elapsed_seconds ⇒ Object
Whole seconds the current/last thinking phase ran, for the collapse cue.
-
#thinking_finished ⇒ Object
Clears the status row for callers that bracket a synchronous wait with no stream lifecycle of their own — the /probe side-inference (#58).
-
#thinking_painter ⇒ Object
The per-frame paint strategy for the thinking animation, or nil when the output can’t host one (a pipe with no composer).
-
#thinking_started ⇒ Object
Shows the status row during the model wait.
-
#tool_body(text, kind: :plain) ⇒ Object
DISPLAY-ONLY collapse (P2): the transcript shows the head few lines of a tool’s output plus a ‘… +N lines (full output → context)` marker — the FULL output still goes to the model/context unchanged.
-
#tool_chunk(_name, chunk) ⇒ Object
Streamed tool output (shell): same display-only collapse as #tool_body, accumulated across chunks.
-
#tool_finished(name, result: nil) ⇒ Object
Tool finished renders as the compact ‘└ ✓ metric` close row, or `└ ✗ failed · name · error` in red (P10).
-
#tool_started(name, arguments: nil, at: nil) ⇒ Object
Tool started renders as the quiet ‘● name hint` open row (P1).
-
#tty_stdout? ⇒ Boolean
True when $stdout is a real terminal (guarded for IO doubles).
-
#turn_finished ⇒ Object
Marks the end of a TURN (normal completion, error, or interrupt): the one place the turn-scoped ticker thread is allowed to die.
-
#turn_footer(text) ⇒ Object
The STATIC turn footer rail, all dim: ‘┄ turn · 16.6s · 3 tools ┄`.
-
#turn_interrupted ⇒ Object
Commits the standardized interrupt marker right after the partial answer that was kept when a turn is cancelled (Ctrl+C, or the interrupt-by- default Enter): a dim ‘⎿ interrupted` row, house grammar.
-
#turn_started ⇒ Object
Marks the start of a TURN: resets the per-turn stats and starts the status-row engine in its initial “thinking” phase (the P1 wait).
- #with_spinner(message, &block) ⇒ Object
Methods inherited from PrinterBase
#blank_line, #info, #status, #success, #warning
Methods inherited from Base
#blank_line, #blocking_human_input?, #info, #status, #success, #warning
Constructor Details
#initialize(session_id: nil, approval_cache: nil) ⇒ CLI
Returns a new instance of CLI.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/rubino/ui/cli.rb', line 39 def initialize(session_id: nil, approval_cache: nil) super() @prompt = TTY::Prompt.new @stream_type = nil @stream_md = nil # StreamingMarkdown buffer, lazily built per content stream @thinking_indicator = false # Turn-scoped status row ("Ruby facet"): ONE ticker thread per turn — # started when the turn (or a stand-alone wait like /probe) starts and # stopped only at turn end / error / interrupt. Events swap its LABEL # under @status_mutex instead of killing the thread, so inter-tool gaps # and post-turn inline jobs keep an animated row instead of dead air. # @thinking_started_at marks the start of the current reasoning phase so # the collapse cue can report the elapsed seconds, and @reasoning_buffer # accumulates the model's reasoning deltas (no longer raw-printed) for # the collapse cue / full aside / ctrl-o. @thinking_thread = nil @status_mutex = Mutex.new @status = nil @turn_active = false @turn_started_at = nil @turn_tool_count = 0 @turn_tok_chars = 0 @thinking_started_at = nil @reasoning_buffer = +"" # The last retained reasoning block (committed/collapsed), revealable via # ctrl-o even after the answer has streamed. Reset per turn. @last_reasoning = nil @last_reasoning_seconds = nil @activity_open = false @activity_name = nil # Rhythm tracker (P3): the kind of the last committed block — :tool # (frames butt together), :gap (a trailing blank is already open, so # the next separator is skipped), :answer, :other. @last_block = :other # Task ids whose FULL report the lifecycle block already rendered # (#subagent_lifecycle): the injected completion notice for one of # these drops its duplicated Result body (#elide_shown_reports). @reported_subagent_ids = [] @session_id = session_id || SecureRandom.uuid @approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance end |
Instance Method Details
#activity_finished(name, metric: nil, failed: false) ⇒ Object
Activity finished. Success is QUIET and compact: ‘└ ✓ 11 lines` — the ✓ already says “done” and the opener row said the name, so repeating both was noise (P10); dim, not green — color is reserved for the one outcome that needs eyes (P1). Failure keeps name + wording, in red: `└ ✗ failed · shell · exit 1` — the word must agree with the glyph; “✗ done” read as if the errored tool had still succeeded (#153).
384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 |
# File 'lib/rubino/ui/cli.rb', line 384 def activity_finished(name, metric: nil, failed: false) @activity_open = false flush_tool_preview_overflow # The metric can carry newlines (e.g. a task_result body): interpolating # it raw would continue flush-left and unstyled on the next lines — # inline it into the ONE styled row instead. inline = metric ? truncate_inline(metric, 120) : nil if failed suffix = inline && !inline.empty? ? " · #{inline}" : "" $stdout.puts @pastel.red(" └ ✗ failed · #{name}#{suffix}") else suffix = inline && !inline.empty? ? " #{inline}" : "" $stdout.puts @pastel.dim(" └ ✓#{suffix}") end @last_block = :tool end |
#activity_started(name, hint: nil) ⇒ Object
Activity started: renders as ‘● name` or `● name hint` — a QUIET dim row with only the ● in cyan. The tool frame is plumbing, not payload: a fully cyan “● running read · path” row outshouted the answer (P1).
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/rubino/ui/cli.rb', line 360 def activity_started(name, hint: nil) # Replace a still-showing "thinking…" indicator before the committed # activity row so it isn't stranded above it (#86): the model emits the # indicator during TTFB and may go straight to a tool call. Collapse any # buffered reasoning into the cue/aside FIRST so a reasoning→tool turn # (no answer text) never strands the thought. collapse_reasoning hint_str = hint ? " #{hint}" : "" # ONE blank before the first frame of a tool run; frames inside a run # butt together, and a gap left by the previous block isn't doubled (P3). $stdout.puts unless %i[tool gap].include?(@last_block) $stdout.puts "#{@pastel.cyan("●")} #{@pastel.dim("#{name}#{hint_str}")}" @activity_open = true @activity_name = name @last_block = :tool reset_tool_preview end |
#answer_gap ⇒ Object
Exactly ONE blank line before the answer payload (P3) — skipped when the previous committed block already left a gap open. No trailing blank: the turn footer attaches directly under the answer. Shared by the non-streamed (#assistant_text) and streamed (#stream) paths so both turns read identically.
721 722 723 724 |
# File 'lib/rubino/ui/cli.rb', line 721 def answer_gap $stdout.puts unless @last_block == :gap @last_block = :answer end |
#approval_requested(summary:, choices:) ⇒ Object
Approval requested: renders as ‘◆ summary`
402 403 404 405 406 407 408 |
# File 'lib/rubino/ui/cli.rb', line 402 def approval_requested(summary:, choices:) $stdout.puts $stdout.puts @pastel.yellow("◆ #{summary}") choices.each do |choice| $stdout.puts @pastel.dim(" [#{choice[:key]}] #{choice[:label]}") end end |
#ask(prompt) ⇒ Object
147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/rubino/ui/cli.rb', line 147 def ask(prompt) # Off a real terminal (piped / non-interactive) there is no user who # can answer, TTY::Prompt would leak raw cursor-control escapes into # the stream (#106), and it would read whatever ambient stdin happens # to hold (#107). Fail closed: no prompt, deterministic nil. return nil unless interactive_terminal? # A mid-turn prompt must own the real terminal: pause the bottom composer # so TTY::Prompt reads the real $stdin and tty-screen probes the real # $stdout (not the write-only StdoutProxy). No-op when no composer is # active (between-turns / piped input). BottomComposer.run_in_terminal { @prompt.ask(prompt) } end |
#ask_timeout_hint ⇒ Object
The honest bound for the ⛔ banner: a blocking ask_parent waits at most tasks.ask_parent_timeout seconds, then the child proceeds with its best judgement (ask_parent_tool.rb). The banner must say so — “no timeout” was a lie unless the bound is explicitly disabled (nil/0) in config (#145).
551 552 553 554 555 556 557 |
# File 'lib/rubino/ui/cli.rb', line 551 def ask_timeout_hint seconds = Rubino.configuration.tasks_ask_parent_timeout.to_i return "no timeout" unless seconds.positive? human = (seconds % 60).zero? ? "#{seconds / 60}m" : "#{seconds}s" "auto-resumes with its best judgement in #{human}" end |
#assistant_text(text) ⇒ Object
Markdown rendering: assistant output rendered as readable text with modest indentation, no box.
704 705 706 707 708 709 710 711 712 713 714 |
# File 'lib/rubino/ui/cli.rb', line 704 def assistant_text(text) return if text.nil? || text.to_s.empty? # A progress indicator must be REPLACED by its result, never left as # residue above the answer (#86). On the non-streaming path nothing # else clears the transient "thinking…" line before the committed # answer, so collapse any buffered reasoning + clear the animation first. collapse_reasoning answer_gap commit_markdown_block(text) end |
#body(text) ⇒ Object
Body text rendered with modest indentation (no big box).
411 412 413 414 415 416 417 |
# File 'lib/rubino/ui/cli.rb', line 411 def body(text) return if text.nil? || text.to_s.empty? text.each_line do |line| $stdout.puts " #{line.chomp}" end end |
#box_close(*_pieces, color: nil) ⇒ Object
1309 1310 1311 1312 |
# File 'lib/rubino/ui/cli.rb', line 1309 def box_close(*_pieces, color: nil) # Compact: close the activity activity_finished(@activity_name || "done", failed: color == :red) end |
#box_open(*pieces, at: nil, color: nil) ⇒ Object
— Legacy box methods (used by print_session_history replay) —
1303 1304 1305 1306 1307 |
# File 'lib/rubino/ui/cli.rb', line 1303 def box_open(*pieces, at: nil, color: nil) # Compact: just print the activity name type = pieces.first.to_s activity_started(type) end |
#branch_confirmation(new_id:, parent_id:, title:, included_probe:) ⇒ Object
Confirms a ‘/branch` fork in the dim block from the locked UX: the new session id + title, the parent it inherits from, and the literal way back (`/sessions <parent>`), bracketed by `┄ branched ┄` / `┄ now in <id> ┄` rails. The CLI flips the prompt chip to `branch:<id> ❯` after.
579 580 581 582 583 584 585 586 587 588 589 590 591 592 |
# File 'lib/rubino/ui/cli.rb', line 579 def branch_confirmation(new_id:, parent_id:, title:, included_probe:) short_new = new_id.to_s[0..3] short_parent = parent_id.to_s[0..3] seed = "inherits #{short_parent} ▸ up to here" seed += " + the probe above" if included_probe $stdout.puts $stdout.puts @pastel.dim("┄ branched ┄#{"─" * 50}") label = title.to_s.strip.empty? ? "" : %( "#{title}") $stdout.puts @pastel.dim("┊ new session #{short_new}#{label}") $stdout.puts @pastel.dim("┊ #{seed}") $stdout.puts @pastel.dim("┊ original #{short_parent} left intact — /sessions #{short_parent} to return") $stdout.puts @pastel.dim("┄ now in #{short_new} ┄#{"─" * 42}") $stdout.puts end |
#cancellable_prompt ⇒ Object
A DEDICATED TTY::Prompt for cancellable pickers, with Esc bound to the same InputInterrupt Ctrl-C raises (#73): tty-reader parses full escape sequences, so arrows (ESC [ A…) never trip :keyescape — only a lone Esc does. Deliberately separate from the shared @prompt so the approval menu’s keymap is untouched (an Esc there must not become a deny).
227 228 229 230 231 |
# File 'lib/rubino/ui/cli.rb', line 227 def cancellable_prompt @cancellable_prompt ||= TTY::Prompt.new.tap do |picker| picker.on(:keyescape) { raise TTY::Reader::InputInterrupt } end end |
#clear_line ⇒ Object
In-place clear of the current row (CR + erase-line) before a committed line lands. Purely a cursor-positioning nicety, so it is gated on a real TTY: into a pipe there is no cursor and the raw ‘e[2K` would leak as literal bytes into the cooked output (#56).
1006 1007 1008 1009 1010 |
# File 'lib/rubino/ui/cli.rb', line 1006 def clear_line return unless tty_stdout? $stdout.print("\r\e[2K") end |
#commit_markdown_block(text) ⇒ Object
Renders a markdown string to committed, styled lines above the composer (each line as ‘$stdout.puts “#MD_MARGIN#line”`). Shared by #assistant_text and the per-block streaming path so both apply the identical rendering.
736 737 738 739 740 |
# File 'lib/rubino/ui/cli.rb', line 736 def commit_markdown_block(text) return if text.nil? || text.to_s.empty? render_markdown_block(text).each { |line| $stdout.puts "#{MD_MARGIN}#{line}" } end |
#compression_finished(metadata, at: nil) ⇒ Object
1147 1148 1149 1150 |
# File 'lib/rubino/ui/cli.rb', line 1147 def compression_finished(, at: nil) saved = [:saved_tokens] || ["saved_tokens"] || 0 $stdout.puts @pastel.dim("┄ compacted · saved #{saved} tok ┄") end |
#compression_started(at: nil) ⇒ Object
1142 1143 1144 1145 |
# File 'lib/rubino/ui/cli.rb', line 1142 def compression_started(at: nil) $stdout.puts $stdout.puts @pastel.dim("┄ compacting context… ┄") end |
#confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) ⇒ Boolean
Approval prompt with session memory. Mirrors UI::API#confirm: a prior “session”/“always_*” decision (or a persisted prefix) for this scope —or its tool-wide parent — short-circuits the prompt so the same call isn’t re-asked. Decisions are mapped to the SAME cache/persister actions the HTTP path uses, so CLE and API persist identical DERIVED RULES to ‘security.command_allowlist` for the “always” forms:
:once — approve this call only (nothing remembered)
:always_prefix — persist the derived PREFIX rule (offered only when a
prefix is derivable AND the command isn't dangerous)
:always_command — persist the NARROW rule (pattern key if dangerous,
else the exact command); survives restart
:always_tool — CLI-ONLY convenience: remember the whole tool for the
session (never an HTTP decision, never persisted)
:no — deny this call
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 |
# File 'lib/rubino/ui/cli.rb', line 256 def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) return true if approval_cached?(scope) # Finalize any live streaming state before the approval card so the card # header doesn't glue onto it ("thinking…⚠ shell wants:" or a # reasoning tail like "Let me run this.⚠ shell wants…"). The model # emits reasoning/content right up to the tool call, so the transient # indicator or the in-progress stream tail is still on the current line # when approval is requested. #finalize_stream commits the tail and # clears the indicator, mirroring a normal stream_end. finalize_stream # Attention: the run is now parked on a human decision — ring the # bell/hook so an approval can't sit unseen behind a quiet terminal. notifier.needs_approval(question.to_s) # ⚠ is the attention glyph (P7): ◆ belongs to the animated status row. rule = derive_rule(tool, command, pattern_key) $stdout.puts @pastel.yellow("⚠ #{question}") # The danger annotation is the single most safety-relevant line on the # card, so it must be the MOST prominent — red + bold, not dim (#83). $stdout.puts @pastel.red.bold(" ⚠ #{description}") unless description.to_s.empty? choice = approval_choice(rule, tool: tool) approved = apply_choice(choice, scope: scope, command: command, rule: rule) # First plain "Approve once" of the session: point at the session-scope # menu options so a multi-edit refactor doesn't keep interrupting # without the user knowing it can stop (#110). Presentation only — the # approval model is untouched. session_scope_tip(tool, choice) if approved # A deny is a safety action: confirm explicitly that nothing ran, in the # same red ✗ styling failed tools use, so "Done." can't be read as "ran" # (#83). Approve/allow paths are unchanged. denied(tool) unless approved approved end |
#confirm_destructive(question) ⇒ Object
A destructive yes/No confirm — NOT the tool-approval menu (#218). Deleting a session or forgetting a fact is not a tool/command the model proposed, so the “Approve once / this command / this tool” vocabulary is wrong, and its highlighted default (Approve) turns a stray Enter or a piped answer into a data-loss. This defaults to No: blank/Esc/EOF and every non-interactive path (piped stdin) decline, and only an explicit “y”/“yes” proceeds. Returns true only when the user affirmatively agreed.
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/rubino/ui/cli.rb', line 300 def confirm_destructive(question) $stdout.puts @pastel.yellow("⚠ #{question}") # Off a real terminal there is no one to answer; fail closed (decline) # so a piped `n` — or any pipe at all — can never destroy (#218). return false unless interactive_terminal? answer = BottomComposer.run_in_terminal do @prompt.yes?(@pastel.bold("Proceed?"), default: false) end !!answer rescue TTY::Reader::InputInterrupt # Esc / Ctrl-C mid-prompt: treat as decline, never destroy. $stdout.puts false end |
#denied(tool = nil) ⇒ Object
Explicit, visible confirmation that a denied command was NOT executed.
331 332 333 334 |
# File 'lib/rubino/ui/cli.rb', line 331 def denied(tool = nil) label = tool ? "#{tool} command" : "command" error("#{label} denied — not executed") end |
#display_width(str) ⇒ Object
143 144 145 |
# File 'lib/rubino/ui/cli.rb', line 143 def display_width(str) Unicode::DisplayWidth.of(str.to_s) end |
#elide_shown_reports(text) ⇒ Object
Drops the Result body from a completion notice whose report the lifecycle block ALREADY rendered in full (#subagent_lifecycle), so the user doesn’t read the same report twice — once at completion and again when the queued notice is injected next turn. DISPLAY-ONLY: the model-facing injected text is untouched. Anchored to the notice shape TaskTool#completion_notice emits; an unmatched notice renders whole (duplicated beats lost). Each id is consumed on first elision.
671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 |
# File 'lib/rubino/ui/cli.rb', line 671 def elide_shown_reports(text) ids = @reported_subagent_ids return text if ids.nil? || ids.empty? ids.dup.each do |id| quoted = Regexp.escape(id) pattern = Regexp.new( "^(\\[background-task\\] Task #{quoted} \\([^)]*\\) completed\\.)\n" \ "Result:\n.*?\n\\(full result via task_result\\(\"#{quoted}\"\\)\\)", Regexp::MULTILINE ) replaced = text.sub(pattern) do "#{::Regexp.last_match(1)} (report shown above — full result via task_result(\"#{id}\"))" end next if replaced == text text = replaced ids.delete(id) end text end |
#erase_picker_frame(choice_count) ⇒ Object
Clears a cancelled picker’s drawn frame: 1 header row + the visible menu rows (tty-prompt paginates at PICKER_PAGE_SIZE). Walks the cursor up to the header column-0 and erases everything below it, leaving the terminal exactly as it was before the picker opened.
215 216 217 218 219 220 |
# File 'lib/rubino/ui/cli.rb', line 215 def erase_picker_frame(choice_count) rows = 1 + [choice_count, PICKER_PAGE_SIZE].min $stdout.print(TTY::Cursor.column(1)) $stdout.print(TTY::Cursor.up(rows)) $stdout.print(TTY::Cursor.clear_screen_down) end |
#error(message) ⇒ Object
A turn that ends in ERROR must tear down the live “thinking…” animation (and any open stream) BEFORE the error line prints — otherwise the ticking row strands below the error and keeps interleaving into every subsequent print until a full repaint (#74). The success path settles via stream_end/collapse_reasoning; this gives the error path the same cleanup. Idempotent — a no-op for errors printed outside a turn.
425 426 427 428 429 430 431 432 |
# File 'lib/rubino/ui/cli.rb', line 425 def error() finalize_stream # An error tears the turn-scoped status row down entirely (#74): the # next model attempt (retry/fallback) restarts it via thinking_started. status_stop @thinking_indicator = false super end |
#grid_overflows?(headers, rows) ⇒ Boolean
True when the natural grid width (column maxima + unicode borders + padding) won’t fit the terminal. Measured by display width so wide glyphs count as 2. Computed directly so we never have to render-then- measure (which would probe the terminal and crash on a StringIO).
109 110 111 112 113 114 115 116 117 |
# File 'lib/rubino/ui/cli.rb', line 109 def grid_overflows?(headers, rows) col_widths = Array.new(headers.size, 0) ([headers] + rows).each do |row| row.each_with_index { |cell, i| col_widths[i] = [col_widths[i], display_width(cell.to_s)].max } end # Per column: 1 left border + 2 padding + content; plus 1 closing border. natural = col_widths.sum { |w| w + 3 } + 1 natural > terminal_cols end |
#hint_row(command, description) ⇒ Object
Welcome-panel hint row (P8): the actionable command is the ONE cyan accent; its description stays plain.
351 352 353 |
# File 'lib/rubino/ui/cli.rb', line 351 def hint_row(command, description) $stdout.puts " #{@pastel.cyan(command.to_s.ljust(9))} #{description}" end |
#input_injected(text) ⇒ Object
Confirms text the loop picked up mid-turn and injected into the CURRENT turn (Phase-2 steering). Rendered dim on its own line, prefixed ‘↳`, so the user sees their interjection landed without it competing with the streaming assistant output. Leading CR + clear-line so it sits cleanly even if the cursor is mid-stream-chunk.
A multi-line injection (a ‘[background-task] … Result:` completion notice carrying the child’s markdown report) keeps the dim ‘↳` prefix on its FIRST line only; the body renders through the same markdown pipeline as assistant answers, so the child’s report shows styled headings/bold instead of literal ‘##`/`**` (#139).
An injected line that carried a live “⏳ queued:” indicator (an Alt+Enter / “/queued” item the loop folded into the current turn) has been CONSUMED — drop its indicator, or it would sit above the input forever for a message that already ran (#129).
648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 |
# File 'lib/rubino/ui/cli.rb', line 648 def input_injected(text) return if text.nil? || text.to_s.empty? if (composer = BottomComposer.current) # The loop coalesces several drained lines into one injection — match # the whole text AND each line so every consumed indicator clears. composer.commit_queued(text) text.to_s.split("\n").each { |line| composer.commit_queued(line) } end clear_line first, rest = elide_shown_reports(text.to_s).split("\n", 2) $stdout.puts @pastel.dim("↳ received while working: #{first}") commit_markdown_block(rest) if rest && !rest.strip.empty? $stdout.flush end |
#interactive_terminal? ⇒ Boolean
True when both ends are a real interactive terminal — the shared gate for every interactive prompt/menu (#ask / #select): off a TTY they return nil instead of rendering ANSI into a pipe.
While a bottom composer owns the screen, $stdout is the WRITE-ONLY StdoutProxy (tty? deliberately false) but the terminal itself is real —BottomComposer.active? gates composer creation on both ends being TTYs. Probing the swapped global would wrongly bail a picker opened from under the pinned prompt (the Esc-Esc rewind), so a live composer answers the question directly; run_in_terminal then restores the real IOs for the prompt’s lifetime.
172 173 174 175 176 177 178 |
# File 'lib/rubino/ui/cli.rb', line 172 def interactive_terminal? return true if BottomComposer.current $stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty? rescue StandardError false end |
#job_enqueued(type) ⇒ Object
1265 1266 1267 |
# File 'lib/rubino/ui/cli.rb', line 1265 def job_enqueued(type) puts_colored(:dim, " ⊕ Job enqueued: #{type}") if Rubino.configuration.ui_verbose? end |
#job_finished(type) ⇒ Object
1281 1282 1283 1284 |
# File 'lib/rubino/ui/cli.rb', line 1281 def job_finished(type) puts_colored(:dim, " ■ Job finished: #{type}") if Rubino.configuration.ui_verbose? clear_thinking_indicator if @turn_active end |
#job_started(type) ⇒ Object
Post-turn inline jobs (P6): the aux-LLM memory extract / skill distill used to freeze the UI for seconds after the footer. The turn-scoped status row is still alive here (it stops at #turn_finished, not at the footer), so swap its label to “polishing · <job>” while each job runs.
1273 1274 1275 1276 1277 1278 1279 |
# File 'lib/rubino/ui/cli.rb', line 1273 def job_started(type) puts_colored(:dim, " ▶ Job started: #{type}") if Rubino.configuration.ui_verbose? return unless @turn_active && thinking_painter @thinking_indicator = true status_show("polishing", phase: :job, hint: job_status_label(type)) end |
#job_status_label(type) ⇒ Object
1286 1287 1288 |
# File 'lib/rubino/ui/cli.rb', line 1286 def job_status_label(type) JOB_STATUS_LABELS[type.to_s] || type.to_s end |
#markdown_width ⇒ Object
Column budget for markdown rendering: terminal width minus the MD_MARGIN indent applied to every committed line. Headless-safe (falls back to 80).
‘winsize` can under-report during the bottom-composer raw-mode TUI while a table is still streaming, returning a tiny/zero column count (#95). Treat any non-positive width as “unknown” and fall back to 80, and never let the budget drop below MIN_MARKDOWN_WIDTH, so columns stay readable mid-stream.
772 773 774 775 776 777 778 779 780 |
# File 'lib/rubino/ui/cli.rb', line 772 def markdown_width cols = begin IO.console&.winsize&.last rescue StandardError nil end cols = 80 unless cols&.positive? [cols - MD_MARGIN.length, MIN_MARKDOWN_WIDTH].max end |
#mode_changed(name, previous: nil) ⇒ Object
1251 1252 1253 1254 1255 1256 |
# File 'lib/rubino/ui/cli.rb', line 1251 def mode_changed(name, previous: nil) arrow = previous && previous != name ? "#{previous} → #{name}" : name.to_s text = "┄ mode #{arrow} ┄" $stdout.puts $stdout.puts(name.to_sym == :yolo ? @pastel.yellow(text) : @pastel.dim(text)) end |
#monotonic_now ⇒ Object
955 956 957 |
# File 'lib/rubino/ui/cli.rb', line 955 def monotonic_now Process.clock_gettime(Process::CLOCK_MONOTONIC) end |
#note(text) ⇒ Object
Free-line annotation rendered as ‘┄ message ┄`, dim.
471 472 473 474 475 476 477 |
# File 'lib/rubino/ui/cli.rb', line 471 def note(text) return if text.nil? || text.to_s.empty? $stdout.puts unless @last_block == :gap $stdout.puts @pastel.dim("┄ #{text} ┄") @last_block = :other end |
#notifier ⇒ Object
The attention notifier (terminal bell + optional command hook). Public so the background-task plumbing can ring it when a child parks on an approval (TaskTool#approval_handler_for).
84 85 86 |
# File 'lib/rubino/ui/cli.rb', line 84 def notifier @notifier ||= Notifier.new end |
#paint_live(frame) ⇒ Object
Paints (or, with an empty frame, clears) the ONE transient live row through whichever seam owns the bottom of the screen, resolved per call:
* during a turn $stdout is the StdoutProxy — #live replaces the
composer's transient row under its render mutex;
* an ACTIVE composer without the proxy is painted via
BottomComposer#set_partial — same row, same mutex — NEVER with a raw
CR repaint that would clobber the pinned prompt line (#169);
* a bare TTY with no composer (the cooked /probe wait, #58; one-shot)
repaints in place via CR + clear-line;
* a pipe hosts nothing — raw escapes must not leak into the cooked
output (#56).
981 982 983 984 985 986 987 988 989 990 991 992 993 |
# File 'lib/rubino/ui/cli.rb', line 981 def paint_live(frame) if $stdout.respond_to?(:live) $stdout.live(frame) elsif (composer = BottomComposer.current) composer.set_partial(frame) elsif tty_stdout? # The bare-TTY repaint owns ONE row (CR + clear-line): show only the # last line of a multi-line frame so the in-place repaint can't wrap # and leave residue it can never erase. $stdout.print("\r\e[2K#{frame.to_s.split("\n").last}") $stdout.flush end end |
#panel_line(label, value, pointer: nil) ⇒ Object
Panel color diet (P8): dim label, PLAIN value, cyan reserved for the actionable pointer (‘(use /mcp)`). The ljust width matches the /status grid so values line up in one column.
343 344 345 346 347 |
# File 'lib/rubino/ui/cli.rb', line 343 def panel_line(label, value, pointer: nil) row = " #{@pastel.dim(label.to_s.ljust(10))} #{value}" row += " #{@pastel.cyan(pointer)}" if pointer $stdout.puts row end |
#probe_aside(answer) ⇒ Object
Renders an ephemeral ‘probe` answer in the dim, fenced aside that the locked UX prescribes: an opening `┄ probe (ephemeral · not saved) ┄` rail, the answer body on a dim `┊` left-rail, then a closing `┄ vanished · main thread untouched ┄` rail. The whole block is dim and never enters scrollback as a “real” answer — it is the visual contract that nothing here was saved. Same render family as #note / #mode_changed.
565 566 567 568 569 570 571 572 573 |
# File 'lib/rubino/ui/cli.rb', line 565 def probe_aside(answer) $stdout.puts $stdout.puts @pastel.dim("┄ probe (ephemeral · not saved) ┄#{"─" * 28}") answer.to_s.each_line do |line| $stdout.puts @pastel.dim("┊ #{line.chomp}") end $stdout.puts @pastel.dim("┄ vanished · main thread untouched ┄#{"─" * 25}") $stdout.puts end |
#queued(text) ⇒ Object
Echoes a line the user typed mid-turn, parked for the next turn. Rendered dim on its own line, prefixed ‘▸`, so the steered text stays visible without competing with the streaming assistant output. Starts with a CR + clear-line so it lands cleanly even if the cursor is sitting after a partial stream chunk.
624 625 626 627 628 629 630 |
# File 'lib/rubino/ui/cli.rb', line 624 def queued(text) return if text.nil? || text.to_s.empty? clear_line $stdout.puts @pastel.dim("queued ▸ #{text}") $stdout.flush end |
#reasoning_changed(mode, previous: nil) ⇒ Object
‘/reasoning <mode>`: confirm the session render-mode switch. The actual
state change is written to config by the executor so the adapter gate
(which reads config) and this render path stay on one source of truth.
┄ reasoning collapsed → full ┄
Switching to ‘hidden` gets an explanatory line instead of the terse arrow — “hidden” is otherwise opaque (no cue, no aside), so we spell out what it does and how to bring reasoning back.
1226 1227 1228 1229 1230 1231 1232 1233 1234 |
# File 'lib/rubino/ui/cli.rb', line 1226 def reasoning_changed(mode, previous: nil) $stdout.puts if mode.to_sym == :hidden $stdout.puts @pastel.dim("┄ reasoning hidden — won't be shown (ctrl-o or /reasoning to bring it back) ┄") else arrow = previous && previous != mode ? "#{previous} → #{mode}" : mode.to_s $stdout.puts @pastel.dim("┄ reasoning #{arrow} ┄") end end |
#reasoning_mode ⇒ Object
The active reasoning render mode (:hidden | :collapsed | :full), resolved from config (which /reasoning writes to, so the adapter gate and this render path share one source of truth). Handles the legacy show_reasoning back-compat mapping.
1016 1017 1018 |
# File 'lib/rubino/ui/cli.rb', line 1016 def reasoning_mode Config::ReasoningPrefs.mode(Rubino.configuration) end |
#reasoning_status(mode) ⇒ Object
‘/reasoning` with no arg: confirm the current render mode in house style.
┄ reasoning: collapsed ┄
1214 1215 1216 1217 |
# File 'lib/rubino/ui/cli.rb', line 1214 def reasoning_status(mode) $stdout.puts $stdout.puts @pastel.dim("┄ reasoning: #{mode} ┄") end |
#redisplay_idle_prompt ⇒ Object
Ask Reline to repaint its prompt + current buffer after out-of-band output (the Ctrl+O reveal) has scrolled below the parked idle prompt. Uses the public Reline line-refresh seam; fully guarded so a Reline that lacks it (or a non-Reline input path) degrades to a no-op rather than crashing the prompt. Does NOT attempt to move the reveal above the prompt (that’s the deferred pinned-layout work) — it only restores the prompt line so the cursor isn’t left bare.
1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 |
# File 'lib/rubino/ui/cli.rb', line 1198 def redisplay_idle_prompt return unless defined?(Reline) core = Reline.respond_to?(:core) ? Reline.core : nil line_editor = core&.instance_variable_get(:@line_editor) if line_editor.respond_to?(:rerender) line_editor.rerender elsif core.respond_to?(:line_editor) && core.line_editor.respond_to?(:rerender) core.line_editor.rerender end rescue StandardError nil end |
#remember_reported_subagent(id) ⇒ Object
Bounded memory of lifecycle-rendered report ids (see #elide_shown_reports).
694 695 696 697 698 699 700 |
# File 'lib/rubino/ui/cli.rb', line 694 def remember_reported_subagent(id) return unless id @reported_subagent_ids ||= [] @reported_subagent_ids << id.to_s @reported_subagent_ids.shift while @reported_subagent_ids.size > 32 end |
#render_cards(headers, rows) ⇒ Object
Vertical key/value cards: ‘Label value`, labels padded to a common width, a dim rule between records. No header truncation.
121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/rubino/ui/cli.rb', line 121 def render_cards(headers, rows) label_w = headers.map { |h| display_width(h.to_s) }.max.to_i rule = @pastel.dim("─" * [[terminal_cols, 1].max, 40].min) rows.each_with_index do |row, i| $stdout.puts rule if i.positive? headers.each_with_index do |h, col| label = h.to_s.ljust(label_w + (h.to_s.length - display_width(h.to_s))) $stdout.puts "#{label} #{row[col]}" end end end |
#render_markdown_block(text) ⇒ Object
A markdown string -> Array<String> of ANSI-styled lines (no indent). Tables are fit to the terminal width minus the 2-space indent that #commit_markdown_block adds, so wide tables wrap instead of overflowing.
745 746 747 748 749 750 751 |
# File 'lib/rubino/ui/cli.rb', line 745 def render_markdown_block(text) MarkdownRenderer.new(width: markdown_width).render(text).map do |line_tokens| line_tokens.map do |token, style| style.nil? ? token : apply_style(token, style) end.join end end |
#replay_user_input(text, at: nil) ⇒ Object
Replay user input in compact form
1028 1029 1030 1031 1032 1033 |
# File 'lib/rubino/ui/cli.rb', line 1028 def replay_user_input(text, at: nil) $stdout.puts $stdout.puts @pastel.green("#{text}") $stdout.puts @last_block = :gap end |
#reveal_last_reasoning ⇒ Object
Ctrl+O reveal: re-render the LAST retained reasoning buffer as the full-style ‘┊` aside, committed into scrollback NOW (append-only — a scrollback terminal can’t un-print the committed cue, so this is a one-way reveal of the retained buffer, not a hide-toggle). A no-op when nothing is retained (hidden mode, or no reasoning yet this session). Wired as the BottomComposer’s on_ctrl_o callback; prints through $stdout so it lands above the prompt under the composer’s render mutex.
1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 |
# File 'lib/rubino/ui/cli.rb', line 1159 def reveal_last_reasoning # NOTHING retained (hidden mode never buffered one, or — the common case # on providers that stream no thinking blocks at all — no reasoning ever # arrived): give the advertised key ONE dim line of feedback instead of # a forever-silent no-op that reads as a broken keybinding (#133). One # note per dry spell: further presses stay silent until reasoning is # actually retained (which resets the flag below). if @last_reasoning.nil? || @last_reasoning.strip.empty? unless @no_reasoning_note_shown @no_reasoning_note_shown = true note("no reasoning retained — this provider streamed no thinking blocks") end return end # IDEMPOTENT + SILENT: a scrollback aside can't be un-printed, so # revealing the SAME retained buffer twice would just stack an identical # block. Once this thought has been revealed, any further Ctrl+O is a # true silent no-op — we print NOTHING (no ack line), so a human mashing # Ctrl+O gets silence, not growing scrollback. #collapse_reasoning clears # the flag when a NEW thought is retained, so its first reveal works, and # a new turn resets it so its first reveal works again. return if @last_reasoning_revealed commit_reasoning_aside(@last_reasoning, @last_reasoning_seconds.to_i) @last_reasoning_revealed = true # Re-emit the idle prompt so the cursor returns to a proper prompt line # instead of being stranded on a bare line below the reveal. Guarded — # degrade silently if Reline isn't the active input (e.g. in-turn). redisplay_idle_prompt end |
#select(prompt, choices) ⇒ Object
Arrow-key single-select menu — the SAME TTY::Prompt component the tool approval menu uses (see #approval_choice), so /sessions resume reuses the existing picker rather than introducing a second menu system (#145). choices is an array of [label, value] pairs. Returns the chosen value, or nil when there’s no real terminal (so the caller keeps the non-interactive shortcut). Esc/Ctrl-C cancels and returns nil — Esc via the #cancellable_prompt keyescape binding (#73), Ctrl-C via tty-prompt’s own InputInterrupt; both land in the rescue below.
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/rubino/ui/cli.rb', line 188 def select(prompt, choices) return nil if choices.nil? || choices.empty? return nil unless interactive_terminal? BottomComposer.run_in_terminal do cancellable_prompt.select(prompt, cycle: false, filter: true) do || choices.each { |label, value| .choice label, value } end end rescue TTY::Reader::InputInterrupt # Esc aborts tty-prompt mid-render — the exception unwinds straight out of # its draw loop, so the per-frame refresh that would have CLEARED the just # drawn header + menu never runs. The frame is left committed to the # scrollback (a dead "Resume which session? …" / "Rewind to which # message? …" header + its first row), and repeated cancels stack corpses # (#219). Erase the picker's frame so cancel restores the prompt cleanly — # "nothing changed", as documented. The cursor is parked at the end of the # last menu row, so we walk up over every drawn line and wipe to the end # of the screen. erase_picker_frame(choices.length) nil end |
#separator ⇒ Object
336 337 338 |
# File 'lib/rubino/ui/cli.rb', line 336 def separator $stdout.puts @pastel.dim("─" * 80) end |
#session_scope_tip(tool, choice) ⇒ Object
One dim line, once per session, after the FIRST “Approve once” (#110): the “this tool (this session)” option already exists in the menu, but nothing surfaced it, so users approved every single edit by hand.
319 320 321 322 323 324 325 326 327 328 |
# File 'lib/rubino/ui/cli.rb', line 319 def session_scope_tip(tool, choice) return unless choice == :once return if @session_scope_tip_shown @session_scope_tip_shown = true label = tool.to_s.empty? ? "this tool" : tool $stdout.puts @pastel.dim( %(┄ tip: choose "Approve — this tool (this session)" to stop being asked for #{label} this session ┄) ) end |
#set_subagent_cards ⇒ Object
Repaints the SUBAGENT CARD block in the live region from the BackgroundTasks registry (Variant A). Called whenever a background subagent’s activity changes (a child tool started/finished, a spawn, a completion, an approval request) so the collapsed cards update IN PLACE without flooding scrollback. Renders the registry’s CURRENT live snapshot rather than a single delta, so cards added/removed/updated all converge.
The card block only exists while a turn owns the bottom composer (BottomComposer.current); between turns there is no live region, so this is a quiet no-op (the /agents drill-in covers the idle case). Reads the registry under its own mutex via #running; the formatting is pure.
605 606 607 608 609 610 611 612 613 |
# File 'lib/rubino/ui/cli.rb', line 605 def set_subagent_cards composer = BottomComposer.current return unless composer entries = Tools::BackgroundTasks.instance.running composer.set_cards(subagent_cards.card_lines(entries)) rescue StandardError # A card repaint is cosmetic — never let it break the turn or the child. end |
#stash_probe_draft(text) ⇒ Object
Holds text the user typed during a synchronous /probe wait (#221), so the next idle prompt seeds it back into ‘❯` — the wait owns a transient composer to echo input, but it’s torn down before the REPL reopens its idle composer, so the buffer is parked here in between.
944 945 946 |
# File 'lib/rubino/ui/cli.rb', line 944 def stash_probe_draft(text) @probe_draft = text end |
#status_back_to_thinking ⇒ Object
After a tool’s ‘└ ✓` close row commits, swap the status row back to the thinking phase (the P4 inter-tool gap) with the accumulated stats. The live row count is a simple per-turn UI tally — the footer’s exact ran/denied split from the Loop stays authoritative.
1132 1133 1134 1135 1136 1137 1138 1139 1140 |
# File 'lib/rubino/ui/cli.rb', line 1132 def status_back_to_thinking return unless @turn_active @turn_tool_count += 1 return unless thinking_painter @thinking_indicator = true status_show("thinking", phase: :thinking) end |
#stream(chunk) ⇒ Object
— Streaming (unchanged except visual, now uses assistant_text) —
784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 |
# File 'lib/rubino/ui/cli.rb', line 784 def stream(chunk) type = chunk[:type] || :content text = chunk[:text].to_s return if text.empty? @turn_tok_chars += text.length if @turn_active # Reasoning deltas are NEVER raw-printed (that dumped unstyled reasoning # indistinguishable from the answer). Buffer them so the collapse cue / # full aside / ctrl-o reveal can render them in house style instead. The # status row keeps animating (label "thinking") while reasoning # accumulates — and RESUMES if a tool/content block hid it (P4). if type == :thinking @reasoning_buffer << text @thinking_started_at ||= monotonic_now if @turn_active && thinking_painter @thinking_indicator = true status_ensure("thinking", phase: :thinking) end return end # First answer token: collapse any buffered reasoning into scrollback # (cue or aside per mode) before the answer streams below it. The # status row hides while answer text streams — the live tail owns the # transient row until the block ends. collapse_reasoning if @thinking_indicator || !@reasoning_buffer.empty? clear_thinking_indicator if type != @stream_type stream_end if @stream_type @stream_type = type # The streamed answer gets the SAME single committed gap the # non-streamed path gets (P3) — once, when the content stream opens. answer_gap if type == :content end # Signal the bottom composer that ANSWER content is now actively # streaming so it defers a mid-stream Ctrl+O reveal (D1) instead of # bisecting the answer. Thinking deltas never reach here (they return # early above), so the thinking phase stays "not streaming" and its # commits still land cleanly above. mark_content_streaming(true) stream_content(text) end |
#stream_block_end(_message_id = nil) ⇒ Object
Block boundary on the STREAMING path, driven by the adapter’s after_message callback (one assistant message == one content block; on a multi-step tool turn several blocks stream within one model call). Commits the in-flight block’s tail and clears @stream_type so the status row can resume between blocks (the P4 inter-tool gap) and a later #thinking_started isn’t gated out by a stale open stream. Idempotent: a no-op when no stream is open (non-streaming path, or the boundary for a block that carried no content).
853 854 855 856 857 858 859 860 861 |
# File 'lib/rubino/ui/cli.rb', line 853 def stream_block_end( = nil) return unless @stream_type stream_end return unless @turn_active && thinking_painter @thinking_indicator = true status_ensure("thinking", phase: :thinking) end |
#stream_end ⇒ Object
830 831 832 833 834 835 836 837 838 839 840 841 842 843 |
# File 'lib/rubino/ui/cli.rb', line 830 def stream_end clear_thinking_indicator if @stream_type == :content && @stream_md flush_content_stream elsif @stream_type $stdout.puts end @stream_md = nil @stream_type = nil # The answer block is finished: tell the composer to flush any reveal # that was deferred during the stream so the `┊` aside renders cleanly # AFTER the answer (D1). mark_content_streaming(false) end |
#subagent_ask_banner(id, subagent, question) ⇒ Object
Commits the ⛔ “a subagent needs you” attention banner into scrollback the instant a background child escalates an ask_parent to the human. This is the ATTENTION event (the one-time, unmissable banner); the persistent AMBIENT reminder is the ⛔ card line the live region keeps showing (see UI::SubagentCards#hint_line) so a blocked tree can never hide behind a spinner. The answer verb is /reply <id>; –stop cancels the child. Routed through $stdout so (during a turn) it lands above the bottom composer like every other committed line; between turns it prints inline.
534 535 536 537 538 539 540 541 542 543 544 545 |
# File 'lib/rubino/ui/cli.rb', line 534 def (id, subagent, question) $stdout.puts $stdout.puts @pastel.dim("┄ a subagent needs you ┄") $stdout.puts @pastel.red.bold("⛔ #{id} (#{subagent}) is BLOCKED, waiting on your answer") $stdout.puts @pastel.yellow(" ❓ #{question}") $stdout.puts @pastel.dim(" everything it needs is paused until you answer — #{ask_timeout_hint}") $stdout.puts @pastel.dim(" → /reply #{id} <answer> to answer · /agents #{id} --stop to cancel") $stdout.flush # The ⛔ state is the loudest one — the whole subtree is parked on the # human — so it also rings the attention bell/hook. notifier.blocked("#{id} (#{subagent}) is waiting on your answer") end |
#subagent_cards ⇒ Object
615 616 617 |
# File 'lib/rubino/ui/cli.rb', line 615 def subagent_cards @subagent_cards ||= SubagentCards.new(pastel: @pastel) end |
#subagent_finished(line, id: nil, status: "done", report: nil) ⇒ Object
A background subagent reached a terminal state. Mid-turn the one-line summary is STASHED and folded into the turn footer (P4) so two ‘┄ ┄` rails never stack at turn end (the report still reaches the model via the InputQueue notice, rendered by #input_injected); between turns the full lifecycle block renders immediately.
499 500 501 502 503 504 505 506 |
# File 'lib/rubino/ui/cli.rb', line 499 def subagent_finished(line, id: nil, status: "done", report: nil) if @turn_active && id (@pending_subagent_footers ||= []) << { fold: "#{id} #{status}", line: line, status: status, report: report, id: id } else subagent_lifecycle(line, status: status, report: report, id: id) end end |
#subagent_lifecycle(line, status: "done", report: nil, id: nil) ⇒ Object
ONE lifecycle grammar (P6): the live-card-shaped row (‘▸ sa_e488 · explore · completed · 1 tool · 12s`) — dim; red only on failure — and the child’s FULL report markdown-rendered under its own ‘↳ report:` lead (the #139 fold-in treatment), never amputated to a one-line head. The id is remembered so the completion notice the model receives next turn doesn’t ECHO the same report a second time (#input_injected elides the already-shown Result body).
515 516 517 518 519 520 521 522 523 524 |
# File 'lib/rubino/ui/cli.rb', line 515 def subagent_lifecycle(line, status: "done", report: nil, id: nil) $stdout.puts unless @last_block == :gap $stdout.puts(status == "failed" ? @pastel.red(line) : @pastel.dim(line)) if report && !report.to_s.strip.empty? $stdout.puts @pastel.dim(" ↳ report:") commit_markdown_block(report) remember_reported_subagent(id) end @last_block = :other end |
#suppress_interrupt_marker(value: true) ⇒ Object
One-shot suppression of the next ‘⎿ interrupted` marker (#111). The chat loop sets it when a slash-command submit interrupted a turn with nothing visibly in flight (no stream, no live partial — e.g. only a subagent card animating): the turn LOOKED idle, so the marker would read as a stray artifact above the command’s own output. Consumed by #turn_interrupted; the chat loop resets it at each turn start so a suppression that never fired can’t leak into a later real Ctrl+C.
441 442 443 |
# File 'lib/rubino/ui/cli.rb', line 441 def suppress_interrupt_marker(value: true) @suppress_interrupt_marker = value end |
#table(headers:, rows:) ⇒ Object
Renders a table, degrading to a readable vertical card layout when the full grid would overflow a narrow terminal (#84). The card layout uses FULL field labels (no ‘Cre…`/`Sta…` truncation — each label sits alone with room to spare) and a rule between records so cards don’t run together. Field order is the header order the caller chose, which the list callers now lead with the identifying fields (ID/Title/Created).
94 95 96 97 98 99 100 101 102 103 |
# File 'lib/rubino/ui/cli.rb', line 94 def table(headers:, rows:) if grid_overflows?(headers, rows) render_cards(headers, rows) else tbl = TTY::Table.new(header: headers, rows: rows) # Pin the width explicitly: TTY::Table otherwise probes the terminal # via ioctl, which blows up when $stdout is a StringIO (tests/pipes). $stdout.puts tbl.render(:unicode, padding: [0, 1], width: terminal_cols, resize: false) end end |
#take_probe_draft ⇒ Object
Consumes the parked /probe draft (see #stash_probe_draft), or nil.
949 950 951 952 953 |
# File 'lib/rubino/ui/cli.rb', line 949 def take_probe_draft draft = @probe_draft @probe_draft = nil draft end |
#terminal_cols ⇒ Object
Terminal column count, headless-safe (falls back to 80).
134 135 136 137 138 139 140 141 |
# File 'lib/rubino/ui/cli.rb', line 134 def terminal_cols cols = begin IO.console&.winsize&.last rescue StandardError nil end cols&.positive? ? cols : 80 end |
#think_changed(effort, previous: nil) ⇒ Object
‘/think <level>`: confirm the effort switch.
┄ effort medium → high ┄
1245 1246 1247 1248 1249 |
# File 'lib/rubino/ui/cli.rb', line 1245 def think_changed(effort, previous: nil) arrow = previous && previous != effort ? "#{previous} → #{effort}" : effort.to_s $stdout.puts $stdout.puts @pastel.dim("┄ effort #{arrow} ┄") end |
#think_status(effort) ⇒ Object
‘/think` with no arg: confirm the current effort in house style.
┄ effort: medium ┄
1238 1239 1240 1241 |
# File 'lib/rubino/ui/cli.rb', line 1238 def think_status(effort) $stdout.puts $stdout.puts @pastel.dim("┄ effort: #{effort} ┄") end |
#thinking_elapsed_seconds ⇒ Object
Whole seconds the current/last thinking phase ran, for the collapse cue.
1021 1022 1023 1024 1025 |
# File 'lib/rubino/ui/cli.rb', line 1021 def thinking_elapsed_seconds return 0 unless @thinking_started_at (monotonic_now - @thinking_started_at).to_i end |
#thinking_finished ⇒ Object
Clears the status row for callers that bracket a synchronous wait with no stream lifecycle of their own — the /probe side-inference (#58). Public counterpart to #thinking_started; a no-op when nothing is showing. Outside a turn this also stops the engine thread.
935 936 937 938 |
# File 'lib/rubino/ui/cli.rb', line 935 def thinking_finished clear_thinking_indicator status_stop unless @turn_active end |
#thinking_painter ⇒ Object
The per-frame paint strategy for the thinking animation, or nil when the output can’t host one (a pipe with no composer). Frames go through #paint_live, which re-resolves the right seam on EVERY frame — so a ticker that outlives a composer/proxy swap can never paint through a stale handle (#169).
964 965 966 967 968 |
# File 'lib/rubino/ui/cli.rb', line 964 def thinking_painter return unless $stdout.respond_to?(:live) || BottomComposer.current || tty_stdout? method(:paint_live) end |
#thinking_started ⇒ Object
Shows the status row during the model wait. Mid-turn this only swaps the label back to “thinking” (the engine thread is already running); for a stand-alone wait with no turn bracket — the /probe side-inference (#58) — it starts the engine fresh. Frames go through #paint_live, so mid-turn they pass the composer’s render mutex; on a BARE TTY with no #live seam the row repaints in place via CR + clear-line. Into a pipe it stays a single static dim print — never animate into a non-terminal.
914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 |
# File 'lib/rubino/ui/cli.rb', line 914 def thinking_started return if @stream_type @thinking_started_at ||= monotonic_now unless thinking_painter return if @thinking_indicator @thinking_indicator = true $stdout.print @pastel.dim("thinking…") $stdout.flush return end @thinking_indicator = true status_ensure("thinking", phase: :thinking) end |
#tool_body(text, kind: :plain) ⇒ Object
DISPLAY-ONLY collapse (P2): the transcript shows the head few lines of a tool’s output plus a ‘… +N lines (full output → context)` marker —the FULL output still goes to the model/context unchanged. Governed by display.tool_output_preview_lines (0 = old full dump).
1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 |
# File 'lib/rubino/ui/cli.rb', line 1065 def tool_body(text, kind: :plain) return if text.nil? || text.to_s.empty? limit = tool_preview_limit lines = text.to_s.lines shown = limit.positive? ? lines.first(limit) : lines hidden = lines.size - shown.size write_body_lines(shown.join) do |chomped| if kind == :diff case chomped[0] when "+" then @pastel.green(chomped) when "-" then @pastel.red(chomped) else @pastel.dim(chomped) end else @pastel.dim(chomped) end end $stdout.puts @pastel.dim(" #{hidden_lines_marker(hidden)}") if hidden.positive? @last_block = :tool end |
#tool_chunk(_name, chunk) ⇒ Object
Streamed tool output (shell): same display-only collapse as #tool_body, accumulated across chunks. Lines past the preview budget are counted silently; #activity_finished flushes the ‘… +N lines` marker right before the close row.
1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 |
# File 'lib/rubino/ui/cli.rb', line 1091 def tool_chunk(_name, chunk) return if chunk.nil? || chunk.to_s.empty? limit = tool_preview_limit unless limit.positive? write_body_lines(chunk.to_s) { |chomped| @pastel.dim(chomped) } return end chunk.to_s.each_line do |line| if @tool_preview_shown.to_i < limit @tool_preview_shown = @tool_preview_shown.to_i + 1 write_body_lines(line) { |chomped| @pastel.dim(chomped) } else @tool_preview_hidden = @tool_preview_hidden.to_i + 1 end end @last_block = :tool end |
#tool_finished(name, result: nil) ⇒ Object
Tool finished renders as the compact ‘└ ✓ metric` close row, or `└ ✗ failed · name · error` in red (P10). The `task` tool closes the delegation row: `✓ <subagent>: <summary>`.
1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 |
# File 'lib/rubino/ui/cli.rb', line 1114 def tool_finished(name, result: nil) return delegation_finished(result) if name == "task" failed = result.respond_to?(:errorish?) ? result.errorish? : (result.respond_to?(:success?) && !result.success?) metric = if failed result&.respond_to?(:truncated_preview) ? result.truncated_preview : nil else (result.respond_to?(:metrics) && result.metrics) || (result&.respond_to?(:truncated_preview) ? result.truncated_preview : nil) end activity_finished(name, metric: metric, failed: failed) status_back_to_thinking end |
#tool_started(name, arguments: nil, at: nil) ⇒ Object
Tool started renders as the quiet ‘● name hint` open row (P1). The `task` (delegation) tool gets a dedicated row so the timeline reads as a hand-off, not a generic tool call: `● delegated → <subagent> <prompt>`.
Finalize any OPEN content stream first (#136): on the streaming path the model can emit answer text right up to the tool call (ruby_llm runs the tool mid-stream, so no stream_end intervenes). Without this the pre-tool text stayed buffered in the stream splitter, committed only AFTER the tool card, glued straight onto the post-tool continuation (“…number.Confirmed — …”). Committing it here preserves stream order (text → tool card → text) and the block boundary between the segments. Idempotent: the non-streaming path already closed the stream (Loop#close_intermediate_stream), so this is a no-op there — the same contract #confirm uses before the approval card.
1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 |
# File 'lib/rubino/ui/cli.rb', line 1049 def tool_started(name, arguments: nil, at: nil) finalize_stream return delegation_started(arguments) if name == "task" hint = args_hint(arguments) activity_started(name, hint: hint) # The committed `● name` open row is in scrollback; SWITCH the status-row # label to the tool (P3) instead of leaving the live region dead while # the tool runs. The engine thread stays the same — label swap only. status_show(name, phase: :tool, hint: status_hint(arguments)) if @turn_active end |
#tty_stdout? ⇒ Boolean
True when $stdout is a real terminal (guarded for IO doubles).
996 997 998 999 1000 |
# File 'lib/rubino/ui/cli.rb', line 996 def tty_stdout? $stdout.respond_to?(:tty?) && $stdout.tty? rescue StandardError false end |
#turn_finished ⇒ Object
Marks the end of a TURN (normal completion, error, or interrupt): the one place the turn-scoped ticker thread is allowed to die.
889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 |
# File 'lib/rubino/ui/cli.rb', line 889 def turn_finished elapsed = @turn_active && @turn_started_at ? monotonic_now - @turn_started_at : nil @turn_active = false @thinking_indicator = false status_stop # A completion stashed after the footer printed (or on an interrupted # turn that never got one) must not vanish — flush the full block. pending = Array(@pending_subagent_footers) @pending_subagent_footers = nil pending.each do |p| subagent_lifecycle(p[:line], status: p[:status] || "done", report: p[:report], id: p[:id]) end # Attention signal LAST, with the footer already committed: a LONG # turn rings the bell/hook so a human who looked away comes back; # quick turns stay silent (the notifier's min_turn_seconds gate). notifier.turn_finished(elapsed) if elapsed end |
#turn_footer(text) ⇒ Object
The STATIC turn footer rail, all dim: ‘┄ turn · 16.6s · 3 tools ┄`. No red ◆ — red is the error color; the animated status row keeps its red facet as the living brand mark (P4). Attached directly under the answer with no leading blank (P3). Subagent completions stashed mid-turn (#subagent_finished) fold into the grammar instead of stacking a second `┄ ┄` rail right at turn end:
┄ turn · 16.6s · 3 tools · 105 tok · sa_e488 done ┄
486 487 488 489 490 491 492 |
# File 'lib/rubino/ui/cli.rb', line 486 def (text) pending = Array(@pending_subagent_footers) @pending_subagent_footers = nil line = ([text] + pending.map { |p| p[:fold] }).join(" · ") $stdout.puts @pastel.dim("┄ #{line} ┄") @last_block = :other end |
#turn_interrupted ⇒ Object
Commits the standardized interrupt marker right after the partial answer that was kept when a turn is cancelled (Ctrl+C, or the interrupt-by- default Enter): a dim ‘⎿ interrupted` row, house grammar. Leading CR + clear-line so it lands cleanly even if the cursor is sitting after a partial stream chunk. This is the single visible interrupt notice — the runner no longer also prints a separate “interrupted by user” warning. Tears down a still-ticking “thinking…” animation first, same as the error path (#74) — Loop#stream_end usually already did, but an interrupt raised outside the streaming bracket must settle too. Swallowed once after a QUIET slash-command interrupt (#111, above).
455 456 457 458 459 460 461 462 463 464 465 466 467 468 |
# File 'lib/rubino/ui/cli.rb', line 455 def turn_interrupted finalize_stream # Interrupt = turn end for the status row: kill the engine thread. status_stop @thinking_indicator = false if @suppress_interrupt_marker @suppress_interrupt_marker = false return end clear_line $stdout.puts @pastel.dim(" ⎿ interrupted") $stdout.flush end |
#turn_started ⇒ Object
Marks the start of a TURN: resets the per-turn stats and starts the status-row engine in its initial “thinking” phase (the P1 wait). Called by the chat loop right before the runner takes over; guarded with respond_to? at the call site so other UI adapters are unaffected.
878 879 880 881 882 883 884 885 |
# File 'lib/rubino/ui/cli.rb', line 878 def turn_started @turn_active = true @turn_started_at = monotonic_now @turn_tool_count = 0 @turn_tok_chars = 0 @thinking_indicator = true if thinking_painter status_show("thinking", phase: :thinking) end |
#with_spinner(message, &block) ⇒ Object
1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 |
# File 'lib/rubino/ui/cli.rb', line 1290 def with_spinner(, &block) spinner = TTY::Spinner.new("[:spinner] #{}", format: :dots) spinner.auto_spin result = block.call spinner.success result rescue StandardError => e spinner.error raise e end |