Class: Rubino::UI::CLI

Inherits:
PrinterBase show all
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
FILTER_MENU_HELP =

Help line for the filterable approval menu. tty-prompt’s default help (“(Press ↑/↓ arrow to move, Enter to select and letters to filter)”) advertises typing-to-filter but NOT how to undo it, so a stray keystroke filters the rows and the user is stranded — Esc must NOT be bound here (it would read as a deny — hard constraint), and clearing the filter is otherwise undiscoverable. tty-prompt already binds the keys (list.rb: keydelete → @filter.clear, keybackspace → @filter.pop); we just surface them. Shown on the first render (the discoverable moment), the same place the default help renders (#513-filter).

"(Press ↑/↓ to move, Enter to select, letters to filter; " \
"Del clears the filter, Backspace one char)"
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.

"  "
BODY_MARGIN =

The 2-space left margin every tool OUTPUT-BODY line is printed behind, shared by the first row and the hang-indented continuation rows of a hard-wrapped long line (#write_body_lines, TUI-2 follow-up).

"  "
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
STREAM_STALL_AFTER =

How long the model stream may go silent mid-block before the facet status row resurfaces BELOW the in-flight tail (#21). Set just above a normal stream’s p95 inter-delta gap (~0.25s on MiniMax) so steady streaming never flickers the row, but a real multi-second transport silence (bursty delivery / proxy stall) stops the screen looking frozen.

0.6
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
MODEL_WAIT_LABEL =

The opening “nothing’s happening yet” label (F5), distinct from “thinking” so the ~12s pre-first-token stall doesn’t look like a hang.

"waiting for model…"
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

Methods inherited from PrinterBase

#blank_line, #emit, #emit_blank, #emit_frame, #emit_glyph, #emit_styled, #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, agent_id: :main, approval_handler: nil, budget_handler: nil) ⇒ CLI

Returns a new instance of CLI.

Parameters:

  • session_id (String) (defaults to: nil)

    key for the session approval cache. One CLI process serves exactly one chat session, so a per-process id is the right granularity for “remember for this session” — the cache is in-memory/process-lifetime anyway. Injectable for tests.

  • approval_cache (Run::SessionApprovalCache) (defaults to: nil)

    shared cache so a prior “always” decision short-circuits the prompt, matching UI::API.

  • agent_id (Symbol, String) (defaults to: :main)

    which agent this CLI renders for. :main is the top-level loop; a background subagent gets its registry entry id. Every frame this CLI commits to the bottom composer carries it as ‘origin:`, so the composer’s focus-gate paints ONLY the focused agent and drops the rest (tmux-style unified render). One UI::CLI instance per agent, so the per-turn stream buffers (@stream_md, @reasoning_buffer, …) never cross between concurrently-running agents.

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

    for a BACKGROUND subagent CLI: the gate handler TaskTool wires (TaskTool#approval_handler_for). #confirm delegates to it so an approval-gated child tool surfaces on the entry’s card and PARKS the child thread on a per-entry gate (the parent answers via /agents <id>), instead of the TTY::Prompt path a real terminal uses (a background thread has no terminal). nil ⇒ the normal CLI behaviour.

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

    the budget-extension handler TaskTool wires (TaskTool#budget_handler_for); #select delegates to it when a parked child hits its tool-iteration ceiling (#574). nil ⇒ normal CLI.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/rubino/ui/cli.rb', line 68

def initialize(session_id: nil, approval_cache: nil, agent_id: :main,
               approval_handler: nil, budget_handler: nil)
  super()
  @agent_id           = agent_id
  @approval_handler   = approval_handler
  @budget_handler     = budget_handler
  @prompt             = TTY::Prompt.new
  @stream_type        = nil
  @stream_md          = nil # StreamingMarkdown buffer, lazily built per content stream
  @thinking_indicator = false
  # Latched true for the duration of #turn_interrupted so a late content
  # delta (the adapter's final think-filter flush) can't re-arm a fresh
  # raw live tail under the committed partial block (#265 interrupt ghost).
  @turn_interrupting  = 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   = +""
  # :full-mode LIVE reasoning stream state. @reasoning_md splits the streamed
  # thinking deltas into prose blocks (committed as dim `┊` lines as they
  # finish), and @reasoning_streaming latches true once the opening
  # `┄ thinking ┄` rail has been painted so the close rail / live-tail
  # teardown run exactly once. Both are nil/false outside :full streaming.
  @reasoning_md       = nil
  @reasoning_streaming = false
  # Mid-stream "transport silence" watchdog (#21): while a content/reasoning
  # block streams, the in-flight tail owns the live row and the status row
  # is hidden — so a multi-second silence from a burst-delivering model
  # leaves the screen looking frozen even though the model is just slow.
  # @last_stream_at is bumped on every tail paint / block commit; when it
  # goes silent past STREAM_STALL_AFTER the ticker resurfaces the facet in
  # the footer (the frozen tail stays above the prompt). Touched only under
  # @status_mutex.
  @last_stream_at = nil
  # 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
  @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).



610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'lib/rubino/ui/cli.rb', line 610

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.
  #
  # The metric is UNTRUSTED: for a String-returning tool (e.g. shell_output
  # reading a background buffer) it is the tool's truncated_preview — the
  # raw bytes the shell emitted. A `\e]0;…\a` there would set the window
  # title / a `\e[2J` clear the screen straight from this close row
  # (R3C-1, CWE-150). #truncate_inline flattens newlines but does NOT touch
  # escape bytes, so sanitize the source first.
  inline = metric ? truncate_inline(safe(metric), 120) : nil
  if failed
    suffix = inline && !inline.empty? ? " · #{inline}" : ""
    put_card_row("  └ ✗ failed · #{name}#{suffix}") { |line| @pastel.red(line) }
  else
    suffix = inline && !inline.empty? ? " #{inline}" : ""
    put_card_row("  └ ✓#{suffix}") { |line| @pastel.dim(line) }
  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).



575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
# File 'lib/rubino/ui/cli.rb', line 575

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).
  emit_blank unless %i[tool gap].include?(@last_block)
  # `● <name> <hint>`: a trusted cyan glyph + a dim body. The body's only
  # UNTRUSTED span (the path inside +hint+) was already defanged in
  # #args_hint and wrapped in rubino's own (trusted) OSC 8 link, so the
  # whole row is rubino-built → PATH 2 (#emit_styled) keeps the cyan/dim
  # SGR AND the now-OSC8-preserving sanitizer keeps the legit hyperlink,
  # while still neutralizing any residual danger byte (Cat 2 + Cat 3).
  # DISPLAY-ONLY label resolution: an MCP tool shows `echo (mcp:chaos)`
  # so the user sees external code is running; a built-in is unchanged.
  # The model-facing `name` (and @activity_name, used as a status key) is
  # untouched — this only changes the printed row (#582).
  label = Tools::Registry.display_label(name)
  emit_styled("#{@pastel.cyan("")} #{@pastel.dim("#{label}#{hint_str}")}")
  @activity_open = true
  @activity_name = name
  @last_block = :tool
  reset_tool_preview
end

#answer_gapObject

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.

TUI-4 (the LIVE-render seam): the separator must commit through the SAME atomic composer seam the block content uses (#commit_block_atomic), NOT a bare ‘$stdout.puts`. On the streamed path the post-tool segment paints its first live tail row via the composer’s transient row; a bare buffered ‘$stdout.puts` for the gap could be reordered/overwritten by that repaint, gluing the pre- and post-tool text (“…command.Output: HELLO”) with no separator. Committing the blank as a one-line atomic block lands it in scrollback AHEAD of the live tail, so the gap is real.



1118
1119
1120
1121
# File 'lib/rubino/ui/cli.rb', line 1118

def answer_gap
  commit_block_atomic([""]) unless @last_block == :gap
  @last_block = :answer
end

#approval_requested(summary:, choices:) ⇒ Object

Approval requested: renders as ‘◆ summary`



660
661
662
663
664
665
666
667
668
669
# File 'lib/rubino/ui/cli.rb', line 660

def approval_requested(summary:, choices:)
  emit_blank
  # The summary is derived from the proposed tool/command (untrusted) — the
  # funnel's PATH 1 (#emit) strips escapes before the trusted wrap (R3C-1,
  # CWE-150). Choice labels are rubino's own fixed menu text (trusted).
  emit("#{summary}", style: :yellow)
  choices.each do |choice|
    emit("  [#{choice[:key]}] #{choice[:label]}", style: :dim)
  end
end

#ask(prompt) ⇒ Object



258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/rubino/ui/cli.rb', line 258

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_hintObject

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



899
900
901
902
903
904
905
# File 'lib/rubino/ui/cli.rb', line 899

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.



1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
# File 'lib/rubino/ui/cli.rb', line 1092

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

#auto_open_human_ask(_entry = nil) ⇒ Object

MID-TURN AUTO-OPEN bridge (Option A): a background child just escalated an ask_parent to the HUMAN while the parent turn is busy. If a bottom composer owns the screen we ask IT to surface the answer dropdown by itself — the composer wakes its input thread (self-pipe), snapshots the live draft, runs the dropdown there, delivers via the child’s gate (NEVER the parent turn), then restores the draft. The FIFO drain (answer_all_human) re-reads awaiting_human after each delivery, so several pending asks resolve one at a time and a 2nd child that asks mid-open is picked up on the re-read.

No-op when no turn is live (BottomComposer.current nil) — the idle poll (Handlers::Agents#auto_resolve_pending) covers that path. Called from the CHILD thread (AskParentTool#surface_and_notify); the actual takeover runs on the input thread. Best-effort — a hiccup here must never break the child or the parent turn.

Returns true when a live composer OWNS the screen — i.e. the ask WILL be surfaced in an on-screen dropdown, either now via this takeover OR via the FIFO re-read of an already-running dropdown loop (#486, one-at-a-time). The caller (#surface_and_notify) uses that to SUPPRESS the redundant scrollback ask-banner whose question the dropdown header already shows (#510). Returns false only when there is no composer (the idle path, where /reply is the affordance) or on error.



1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
# File 'lib/rubino/ui/cli.rb', line 1003

def auto_open_human_ask(_entry = nil)
  composer = BottomComposer.current
  return false unless composer
  # Belt-and-suspenders (#513): when the composer is ALREADY suspended the
  # idle resolver (chat_command.rb) is mid-resolution and will surface the
  # child itself — request_takeover would reject this anyway (returns false
  # on @suspended), but bailing here makes it explicit that only ONE path
  # claims the shared composer, so the two threads can't both report
  # success and race the surface.
  return false if composer.suspended?

  handler = Commands::Handlers::Agents.new(ui: self)
  # on_resume repaints the subagent cards from the live registry once the
  # dropdown closes and the composer has resumed — so the aggregated
  # `⛔N subagents waiting on you` hint (the live region's last row, wiped
  # when the takeover suspended it) RELIABLY comes back whenever children
  # are still awaiting_human (several pending, or the human cancelled),
  # instead of staying invisible for the rest of the turn (#475-A).
  #
  # RETURN THE REAL RESULT (#513): request_takeover returns false when NO
  # takeover (and no snapshot/restore) happened — composer not running,
  # suspended, no wake pipe, or the one-at-a-time guard. On the one-at-a-time
  # case the ask is NOT lost (answer_all_human's FIFO re-read surfaces it),
  # but on the OTHER rejections nothing surfaces it on-screen, so the caller
  # must KEEP the scrollback banner + /reply affordance. Hardcoding true here
  # suppressed that banner and stranded the user with only a bell. A
  # redundant banner is strictly safer than a stranded user, so we report
  # the actual takeover result.
  composer.request_takeover(on_resume: -> { set_subagent_cards }) { handler.answer_all_human }
rescue StandardError
  false
end

#body(text) ⇒ Object

Body text rendered with modest indentation (no big box).



672
673
674
675
676
677
678
# File 'lib/rubino/ui/cli.rb', line 672

def body(text)
  return if text.nil? || text.to_s.empty?

  text.each_line do |line|
    emit("  #{line.chomp}")
  end
end

#box_close(*_pieces, color: nil) ⇒ Object



1866
1867
1868
1869
# File 'lib/rubino/ui/cli.rb', line 1866

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



1860
1861
1862
1863
1864
# File 'lib/rubino/ui/cli.rb', line 1860

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.



929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
# File 'lib/rubino/ui/cli.rb', line 929

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
  emit_blank
  emit("┄ branched ┄#{"" * 50}", style: :dim)
  # CWE-150 (#568): the session title is user/model-set — the funnel's
  # PATH 1 (#emit) defangs escapes before the dim branch row's styling.
  label = title.to_s.strip.empty? ? "" : %(  "#{title}")
  emit("┊  new session  #{short_new}#{label}", style: :dim)
  emit("#{seed}", style: :dim)
  emit("┊  original  #{short_parent}  left intact — /sessions #{short_parent} to return", style: :dim)
  emit("┄ now in  #{short_new}#{"" * 42}", style: :dim)
  emit_blank
end

#cancellable_promptObject

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



363
364
365
366
367
# File 'lib/rubino/ui/cli.rb', line 363

def cancellable_prompt
  @cancellable_prompt ||= TTY::Prompt.new.tap do |picker|
    picker.on(:keyescape) { raise TTY::Reader::InputInterrupt }
  end
end

#clear_lineObject

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



1519
1520
1521
1522
1523
1524
1525
# File 'lib/rubino/ui/cli.rb', line 1519

def clear_line
  return unless tty_stdout?

  # rubino's own CR + erase-line — Cat 4 cursor-control frame (no untrusted
  # text), through the single seam.
  emit_frame("\r\e[2K")
end

#clear_stream_regionObject

Fully erase the streaming live tail through the live region’s row-accurate clear (it walks up exactly the rows it painted), so an interrupt can never strand a bounded rolling-tail fragment on screen. Drops the block buffer too, so a stray post-finalize delta has nothing to extend. A no-op once the stream is already closed and the tail blank.



763
764
765
766
767
768
769
770
771
772
# File 'lib/rubino/ui/cli.rb', line 763

def clear_stream_region
  @stream_md = nil
  @stream_type = nil
  # An interrupt mid-:full-reasoning leaves the live tail painted and the
  # latch set; drop both so the torn-down region can't leak a stale aside
  # latch into the next turn's reasoning phase.
  @reasoning_md = nil
  @reasoning_streaming = false
  show_live_tail("")
end

#commit_async_above(lines, gap: false) ⇒ Object

Commits one or more PRE-STYLED async parent-surface lines above the prompt through the SAME live-region paint ordinary committed cards use (R2/Y4). When a bottom composer owns the screen we hand the block to its #print_above: during a turn it commits in one clean frame; while the composer is SUSPENDED (an approval/ask modal owns the raw terminal) it PARKS the line in @parked_writes and #resume flushes it in arrival order at column 0 — so a 2nd subagent’s notice can never tear the active modal or land at an offset column. Off the composer seam (between turns / plain TTY / pipe / tests) it falls back to per-line emit so idle notices and headless runs are unchanged. The lines are rubino-built + already defanged by the caller (PATH 2), so the SGR survives the keep-sgr write.



850
851
852
853
854
855
856
857
858
859
860
# File 'lib/rubino/ui/cli.rb', line 850

def commit_async_above(lines, gap: false)
  rows = (gap ? [""] : []) + Array(lines)
  composer = BottomComposer.current
  if composer
    composer.print_above(rows.join("\n"), origin: @agent_id)
  else
    rows.each { |row| row.empty? ? emit_blank : emit_styled(row) }
  end
rescue StandardError
  # An async-notice paint is cosmetic — never let it break a turn or child.
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.



1138
1139
1140
1141
1142
1143
1144
1145
1146
# File 'lib/rubino/ui/cli.rb', line 1138

def commit_markdown_block(text)
  return if text.nil? || text.to_s.empty?

  # Each rendered line is rubino-built with its own per-token SGR, off a
  # source already sanitize_terminal'd in #render_markdown_block before
  # parse. PATH 2 (#emit_styled) keeps that SGR and strips any residual
  # danger byte.
  render_markdown_block(text).each { |line| emit_styled("#{MD_MARGIN}#{line}") }
end

#compression_finished(metadata, at: nil) ⇒ Object



1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
# File 'lib/rubino/ui/cli.rb', line 1695

def compression_finished(, at: nil)
  saved = [:saved_tokens] || ["saved_tokens"] || 0
  before = [:original_messages] || ["original_messages"]
  after  = [:compacted_messages] || ["compacted_messages"]
  # Show the message-count change alongside the token saving so the notice
  # reads as a CONTINUATION of the same session, not a silent session-swap
  # (item 6): `┄ compacted · saved N tok (X→Y msg) ┄`. The `┄ … ┄` rail
  # (matching the `┄ compacting context… ┄` pre-notice) keeps it visibly
  # inline in the SAME transcript. Falls back to the bare token line when
  # the counts aren't supplied (e.g. the API-shaped metadata).
  msg = before && after ? " (#{before}#{after} msg)" : ""
  emit("┄ compacted · saved #{saved} tok#{msg}", style: :dim)
end

#compression_started(at: nil) ⇒ Object



1690
1691
1692
1693
# File 'lib/rubino/ui/cli.rb', line 1690

def compression_started(at: nil)
  emit_blank
  emit("┄ compacting context… ┄", style: :dim)
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

Parameters:

  • scope (String, nil) (defaults to: nil)

    “<tool>:<command>” cache key from the caller. Nil opts out of memory (legacy callers still get a prompt).

  • tool (String, nil) (defaults to: nil)

    tool name, for rule derivation.

  • command (String, nil) (defaults to: nil)

    literal command/args, for prefix derivation.

  • pattern_key (String, nil) (defaults to: nil)

    matched dangerous-pattern key, if any.

  • description (String, nil) (defaults to: nil)

    dangerous-pattern description, if any.

Returns:

  • (Boolean)

    true when approved.



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# File 'lib/rubino/ui/cli.rb', line 392

def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil)
  return true if approval_cached?(scope)

  # BACKGROUND subagent (Option 2 — approval-surfacing, #86): a child tool
  # needing approval is NOT silently denied and does NOT reach TTY::Prompt
  # (the child runs on a thread with no terminal). Hand off to the wired gate
  # handler: it flips the entry to :needs_approval (card + parent note) and
  # BLOCKS the child thread on a per-entry gate until the human answers via
  # /agents <id>; the returned boolean is the child's decision. "Approve
  # always" is persisted by the parent decision path's allowlist, so the
  # handler only needs the boolean.
  return @approval_handler.call(question, scope: scope, command: command) if @approval_handler

  # 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)
  # The question/description carry the UNTRUSTED command+args the human is
  # about to authorize — THE most security-critical sink (R3C-1, CWE-150).
  # A raw `\e[…` in the command can move the cursor / clear the line and
  # SPOOF what the approval card shows ("rm -rf" hidden, a benign command
  # painted over it), so the human approves something other than what runs.
  # PATH 1 of the output funnel (#emit) strips every escape, THEN applies the
  # trusted style around the now-inert text — the manual safe() wrap is gone.
  emit("#{question}", style: :yellow)
  # 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).
  emit("#{description}", style: %i[red bold]) unless description.to_s.empty?

  choice   = approval_choice(rule, tool: tool)
  approved = apply_choice(choice, scope: scope, command: command, rule: rule)
  # Surface the session-scope escape hatch so a bulk multi-file refactor
  # doesn't re-prompt per file without the user knowing it can stop (#110,
  # F4). Fire on the FIRST "Approve once" of the session AND again the
  # moment a BATCH is detected — a second `:once` for the SAME tool in one
  # turn (the N-edit refactor signature) — since that's exactly when the
  # per-file fatigue starts. Presentation only; the approval model is
  # untouched.
  if approved && choice == :once
    @turn_once_by_tool ||= Hash.new(0)
    @turn_once_by_tool[tool.to_s] += 1
    session_scope_tip(tool, batch: @turn_once_by_tool[tool.to_s] >= 2)
  end
  # 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.



491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/rubino/ui/cli.rb', line 491

def confirm_destructive(question)
  # The question may interpolate an untrusted name (a session title, a fact
  # body) — the funnel's PATH 1 (#emit) strips escapes before the trusted
  # yellow wrap (R3C-1, CWE-150).
  emit("#{question}", style: :yellow)
  # 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.
  emit_blank
  false
end

#denied(tool = nil) ⇒ Object

Explicit, visible confirmation that a denied command was NOT executed.



546
547
548
549
# File 'lib/rubino/ui/cli.rb', line 546

def denied(tool = nil)
  label = tool ? "#{tool} command" : "command"
  error("#{label} denied — not executed")
end

#diff_line_color(line) ⇒ Object

/-/@@ unified-diff coloring shared by streamed diff chunks (#tool_chunk) and the end-of-call diff body (#tool_body). ‘++`/`—` file headers are left dim (not green/red) so they don’t read as added/removed lines.



1647
1648
1649
1650
1651
1652
1653
1654
1655
# File 'lib/rubino/ui/cli.rb', line 1647

def diff_line_color(line)
  case line
  when /\A[-+]{3}\s/, /\A@@/, /\Adiff /, /\Aindex /
    @pastel.dim(line)
  when /\A\+/ then @pastel.green(line)
  when /\A-/  then @pastel.red(line)
  else             @pastel.dim(line)
  end
end

#display_width(str) ⇒ Object

Terminal columns a string occupies. SGR colour escapes (‘e[…m`) take ZERO columns, so they’re stripped before measuring — otherwise a colored /agents status cell measured far wider than it draws and padded the grid crooked (FRICTION-3). Wide glyphs still count as 2.



254
255
256
# File 'lib/rubino/ui/cli.rb', line 254

def display_width(str)
  Unicode::DisplayWidth.of(str.to_s.gsub(Util::Output::SGR_RE, ""))
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.



351
352
353
354
355
356
# File 'lib/rubino/ui/cli.rb', line 351

def erase_picker_frame(choice_count)
  rows = 1 + [choice_count, PICKER_PAGE_SIZE].min
  # rubino's OWN cursor moves to wipe the cancelled picker frame — no
  # untrusted text → one Cat 4 frame through the single seam.
  emit_frame("#{TTY::Cursor.column(1)}#{TTY::Cursor.up(rows)}#{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.



686
687
688
689
690
691
692
693
# File 'lib/rubino/ui/cli.rb', line 686

def error(message)
  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_border(widths, left, mid, right) ⇒ Object



199
200
201
# File 'lib/rubino/ui/cli.rb', line 199

def grid_border(widths, left, mid, right)
  left + widths.map { |w| "" * (w + 2) }.join(mid) + right
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).

Returns:

  • (Boolean)


215
216
217
218
219
220
221
222
223
# File 'lib/rubino/ui/cli.rb', line 215

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

#grid_row(cells, widths) ⇒ Object



203
204
205
206
207
208
209
# File 'lib/rubino/ui/cli.rb', line 203

def grid_row(cells, widths)
  padded = widths.each_index.map do |i|
    cell = cells[i].to_s
    " #{cell}#{" " * (widths[i] - display_width(cell))} "
  end
  "#{padded.join("")}"
end

#handle_thinking_delta(text) ⇒ Object

A reasoning delta. The text is ALWAYS buffered (the collapse cue / ctrl-o reveal render it in house style off @reasoning_buffer). In :full mode it is ADDITIONALLY streamed live as a dim ‘┊` aside so the pre-tool-call window fills with flowing thought instead of a bare spinner (Hermes’ _fire_reasoning_delta); the live tail owns the transient row, so the status spinner is NOT animated here. :collapsed/:hidden keep the original spinner-only behaviour — the status row animates (“thinking”), RESUMING if a tool/content block hid it (P4); no reasoning text is shown.



1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
# File 'lib/rubino/ui/cli.rb', line 1264

def handle_thinking_delta(text)
  @reasoning_buffer << text
  @thinking_started_at ||= monotonic_now

  if reasoning_mode == :full
    stream_reasoning_live(text)
  elsif @turn_active && thinking_painter
    @thinking_indicator = true
    status_ensure("thinking", phase: :thinking)
  end
end

#hint_row(command, description) ⇒ Object

Welcome-panel hint row (P8): the actionable command is the ONE cyan accent; its description stays plain.



566
567
568
# File 'lib/rubino/ui/cli.rb', line 566

def hint_row(command, description)
  emit_styled("    #{@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).



1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
# File 'lib/rubino/ui/cli.rb', line 1070

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 = text.to_s.split("\n", 2)
  # The injected first line is a subagent completion notice (UNTRUSTED,
  # R3C-1 / CWE-150). PATH 1: #emit strips every escape and dims the inert
  # text — the manual safe + @pastel.dim wrap is gone. The rest goes
  # through #commit_markdown_block, which renders structured tokens.
  emit("↳ received while working: #{first}", style: :dim)
  commit_markdown_block(rest) if rest && !rest.strip.empty?
  $stdout.flush
end

#interactive?Boolean

The UI-contract capability ToolExecutor reads to decide whether a tool that needs approval can actually be put in front of a human (#260). On the CLI this is exactly “are we on a real TTY” — a piped / redirected ‘rubino chat` run has no one to answer, so the executor fails closed instead of hanging or auto-running.

Returns:

  • (Boolean)


296
297
298
299
300
301
302
303
304
# File 'lib/rubino/ui/cli.rb', line 296

def interactive?
  # A BACKGROUND subagent CLI has no terminal of its own, but WITH a wired
  # gate handler it can still put an approval in front of a human (park the
  # child on a per-entry gate a /agents <id> decision resolves), so it is
  # interactive — the executor must escalate, not fail closed (#86/#260).
  return true if @approval_handler

  interactive_terminal?
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.

Returns:

  • (Boolean)


283
284
285
286
287
288
289
# File 'lib/rubino/ui/cli.rb', line 283

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



1822
1823
1824
# File 'lib/rubino/ui/cli.rb', line 1822

def job_enqueued(type)
  puts_colored(:dim, "  ⊕ Job enqueued: #{type}") if Rubino.configuration.ui_verbose?
end

#job_finished(type) ⇒ Object



1838
1839
1840
1841
# File 'lib/rubino/ui/cli.rb', line 1838

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.



1830
1831
1832
1833
1834
1835
1836
# File 'lib/rubino/ui/cli.rb', line 1830

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



1843
1844
1845
# File 'lib/rubino/ui/cli.rb', line 1843

def job_status_label(type)
  JOB_STATUS_LABELS[type.to_s] || type.to_s
end

#markdown_widthObject

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.



1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
# File 'lib/rubino/ui/cli.rb', line 1189

def markdown_width
  cols = begin
    IO.console&.winsize&.last
  rescue StandardError
    nil
  end
  cols = 80 unless cols&.positive?
  # Apply the #95 under-report floor to the REAL pane width FIRST, THEN
  # subtract the MD_MARGIN every committed/live line is indented by, so the
  # rendered table plus its margin never exceeds the actual pane (#Y1).
  # Flooring after the subtraction (the old `[cols - margin, FLOOR].max`)
  # let a 40-col pane render a 40-col table that, once margined, spilled to
  # 42 cols and tore/garbled. Clamp the post-margin budget to ≥1 too.
  [[cols, MIN_MARKDOWN_WIDTH].max - MD_MARGIN.length, 1].max
end

#mode_changed(name, previous: nil) ⇒ Object



1808
1809
1810
1811
1812
1813
# File 'lib/rubino/ui/cli.rb', line 1808

def mode_changed(name, previous: nil)
  arrow = previous && previous != name ? "#{previous}#{name}" : name.to_s
  text = "┄ mode #{arrow}"
  emit_blank
  emit(text, style: name.to_sym == :yolo ? :yellow : :dim)
end

#monotonic_nowObject



1422
1423
1424
# File 'lib/rubino/ui/cli.rb', line 1422

def monotonic_now
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

#note(text) ⇒ Object

Free-line annotation rendered as ‘┄ message ┄`, dim.



775
776
777
778
779
780
781
782
783
784
785
786
787
788
# File 'lib/rubino/ui/cli.rb', line 775

def note(text)
  return if text.nil? || text.to_s.empty?

  # ASYNC parent-surface write (R2/Y4): a `note` can fire from a CHILD
  # thread (a 2nd subagent's `● … needs approval` notice) WHILE an approval
  # modal owns the terminal — the composer is suspended and $stdout has been
  # swapped to the raw terminal, so a plain #emit would land mid-line over
  # the modal at an offset column. Route through the committed live-region
  # paint so it PARKS while suspended and flushes at column 0 on resume,
  # stacking cleanly instead of tearing the frame.
  lead = @pastel.dim("#{Util::Output.sanitize_terminal(text.to_s)}")
  commit_async_above([lead], gap: @last_block != :gap)
  @last_block = :other
end

#notifierObject

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



133
134
135
# File 'lib/rubino/ui/cli.rb', line 133

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


1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
# File 'lib/rubino/ui/cli.rb', line 1448

def paint_live(frame)
  # The $stdout proxy belongs to the MAIN turn (the main thread swaps it in);
  # only the main CLI may write through it. A background subagent's CLI runs
  # on its own thread where the GLOBAL $stdout is the main's proxy (or real
  # IO) — writing the sub's tail there would route with the wrong origin. So
  # a non-:main CLI bypasses the proxy and paints the composer directly with
  # its own origin, letting the focus-gate decide if it lands.
  if $stdout.respond_to?(:live) && @agent_id == :main
    $stdout.live(frame)
  elsif (composer = BottomComposer.current)
    composer.set_partial(frame, origin: @agent_id)
  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. The frame is rubino's OWN
    # cursor-control output (CR + \e[2K) wrapping content the caller has
    # already defanged (#margined_tail / #show_reasoning_tail sanitize the
    # model tail; the status frame interpolates only @pastel + a pre-#safe'd
    # hint) → Cat 4's #emit_frame writes it through the single seam without
    # stripping the cursor control, print+flush, timing unchanged.
    emit_frame("\r\e[2K#{frame.to_s.split("\n").last}")
  end
end

#paint_turn_status(frame) ⇒ Object

Routes a TURN STATUS / STALL frame to whichever seam owns the bottom of the screen, resolved per call like #paint_live — but to the FOOTER, not the partial: during a turn a composer owns the screen, so the facet rides its SINGLE footer bar (#set_turn_status) instead of a separate row above the prompt. On a bare TTY with no composer (the cooked /probe wait, #58) there is no footer, so it degrades to the same one-row CR repaint #paint_live uses there. Into a pipe / between turns it is a no-op.



1479
1480
1481
1482
1483
1484
1485
# File 'lib/rubino/ui/cli.rb', line 1479

def paint_turn_status(frame)
  if (composer = BottomComposer.current)
    composer.set_turn_status(frame, origin: @agent_id)
  elsif tty_stdout?
    emit_frame("\r\e[2K#{frame.to_s.split("\n").last}")
  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.



558
559
560
561
562
# File 'lib/rubino/ui/cli.rb', line 558

def panel_line(label, value, pointer: nil)
  row = "  #{@pastel.dim(label.to_s.ljust(10))} #{value}"
  row += "   #{@pastel.cyan(pointer)}" if pointer
  emit_styled(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.



913
914
915
916
917
918
919
920
921
922
923
# File 'lib/rubino/ui/cli.rb', line 913

def probe_aside(answer)
  emit_blank
  emit("┄ probe (ephemeral · not saved) ┄#{"" * 28}", style: :dim)
  answer.to_s.each_line do |line|
    # CWE-150 (#565): the probe answer is model output — the funnel's PATH 1
    # (#emit) defangs escapes before our own (trusted) dim styling.
    emit("#{line.chomp}", style: :dim)
  end
  emit("┄ vanished · main thread untouched ┄#{"" * 25}", style: :dim)
  emit_blank
end

#put_card_row(text) ⇒ Object

Prints a single-line tool-card row (the ‘└ ✓ <preview>` / `└ ✗ …` close row), WRAPPING it to the terminal width and HANG-INDENTING continuation rows under the row’s text column instead of letting a long one-line preview hard-wrap to column 0 at a narrow terminal (TUI-2). The hang column is the leading whitespace + glyph run (‘ └ ✓ ` / ` └ ✗ `), so the wrapped tail lines up under the preview rather than under the `└`. text is already sanitized/safe; the block styles each rendered line.



641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
# File 'lib/rubino/ui/cli.rb', line 641

def put_card_row(text)
  hang = text[/\A\s*└ \S+ /] || text[/\A\s*/]
  body = text[hang.length..] || ""
  # Wrap the BODY at the width left after the hang column, then prefix the
  # hang to EVERY row so the first and the continuations occupy the same
  # left margin (the hang's own glyphs only show on the first row, blanks
  # on the rest). A minimum body budget keeps a very narrow terminal from
  # looping on a 1-col field.
  budget = [terminal_cols - 1 - display_width(hang), 4].max
  rows   = wrap_tail_row(body, budget)
  indent = " " * hang.length
  # The block returns a rubino-styled line (its own @pastel SGR) built from
  # already-sanitized +text+ → PATH 2 (#emit_styled) keeps the SGR, strips
  # any residual danger byte.
  emit_styled(yield("#{hang}#{rows.first}"))
  rows[1..].each { |row| emit_styled(yield("#{indent}#{row}")) }
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.



1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
# File 'lib/rubino/ui/cli.rb', line 1041

def queued(text)
  return if text.nil? || text.to_s.empty?

  clear_line
  # USER-SUPPLIED steered text is UNTRUSTED (CWE-150 — H1). PATH 1 of the
  # output funnel: #emit strips every escape and applies the :dim style
  # around the now-inert text, so the manual sanitize + @pastel.dim wrap is
  # gone. Render-only — the literal text is what runs next turn; only this
  # echo is defanged.
  emit("queued ▸ #{text}", style: :dim)
  $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.



1783
1784
1785
1786
1787
1788
1789
1790
1791
# File 'lib/rubino/ui/cli.rb', line 1783

def reasoning_changed(mode, previous: nil)
  emit_blank
  if mode.to_sym == :hidden
    emit("┄ reasoning hidden — won't be shown (ctrl-o or /reasoning to bring it back) ┄", style: :dim)
  else
    arrow = previous && previous != mode ? "#{previous}#{mode}" : mode.to_s
    emit("┄ reasoning #{arrow}", style: :dim)
  end
end

#reasoning_modeObject

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.



1531
1532
1533
# File 'lib/rubino/ui/cli.rb', line 1531

def reasoning_mode
  Config::ReasoningPrefs.effective_mode(Rubino.configuration)
end

#reasoning_status(mode) ⇒ Object

‘/reasoning` with no arg: confirm the current render mode in house style.

┄ reasoning: collapsed ┄


1771
1772
1773
1774
# File 'lib/rubino/ui/cli.rb', line 1771

def reasoning_status(mode)
  emit_blank
  emit("┄ reasoning: #{mode}", style: :dim)
end

#redisplay_idle_promptObject

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.



1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
# File 'lib/rubino/ui/cli.rb', line 1755

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

#refresh_live_cardsObject

Tick-driven card refresh (called ~1 Hz from the turn status thread) so a live child’s elapsed keeps advancing mid-turn even when it fires no tool events. Skipped when no child is live, so a plain turn pays nothing; #set_subagent_cards coalesces, so an unchanged snapshot never repaints.



971
972
973
974
975
# File 'lib/rubino/ui/cli.rb', line 971

def refresh_live_cards
  set_subagent_cards if Tools::BackgroundTasks.instance.running.any?
rescue StandardError
  nil
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.



227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/rubino/ui/cli.rb', line 227

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|
    emit_styled(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)))
      # row[col] is an already-SGR-sanitized cell; PATH 2 keeps its colour.
      emit_styled("#{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.

The SOURCE text is untrusted (a closed assistant-content block, a subagent report body), so neutralize its terminal-control bytes to visible caret notation BEFORE parsing (CWE-150, R4-F1): a raw ‘e[2J` in the assistant text would otherwise clear/recolor the screen when the committed line printed. Sanitizing the SOURCE (not the rendered lines) leaves the renderer’s OWN trusted ANSI — applied per token below — the only escapes that reach the terminal. This is the shared funnel for the committed block (#commit_markdown_block) and the atomic block (#margined_render), so both paths are covered.



1161
1162
1163
1164
1165
1166
1167
1168
# File 'lib/rubino/ui/cli.rb', line 1161

def render_markdown_block(text)
  text = Util::Output.sanitize_terminal(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

#render_unicode_grid(headers, rows) ⇒ Object

Draws a unicode box grid measuring each column on the DISPLAY width (#display_width strips SGR), so colored cells stay aligned where TTY::Table — which counts escape bytes as columns — would not. One left border + 1 space padding each side, matching TTY::Table’s ‘:unicode` padding: [0, 1] so the colorless path and this one look identical.



184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/rubino/ui/cli.rb', line 184

def render_unicode_grid(headers, rows)
  cols    = headers.size
  widths  = Array.new(cols, 0)
  ([headers] + rows).each do |row|
    row.each_with_index { |cell, i| widths[i] = [widths[i], display_width(cell.to_s)].max }
  end
  # Borders are rubino's own glyphs; rows interpolate already-SGR-sanitized
  # cells. PATH 2 (#emit_styled) keeps the trusted cell colour, strips danger.
  emit_styled(grid_border(widths, "", "", ""))
  emit_styled(grid_row(headers, widths))
  emit_styled(grid_border(widths, "", "", ""))
  rows.each { |row| emit_styled(grid_row(row, widths)) }
  emit_styled(grid_border(widths, "", "", ""))
end

#replay_user_input(text, at: nil) ⇒ Object

Replay user input in compact form. The text is USER-SUPPLIED (a freshly submitted line, a resumed session message, a ‘!` shell echo), so it is routed through Util::Output.sanitize_terminal before it is colored and printed (CWE-150 — H1): an embedded OSC/CSI escape (`e]0;…a` set title, `e[2J` clear screen) would otherwise EXECUTE against the terminal when the transcript echoes it. Render-only — the literal text reached the model already; only this echo is neutralized.



1549
1550
1551
1552
1553
1554
1555
1556
# File 'lib/rubino/ui/cli.rb', line 1549

def replay_user_input(text, at: nil)
  emit_blank
  # USER-SUPPLIED text — the funnel's PATH 1 (#emit) strips every escape
  # before the trusted green wrap (CWE-150 — H1).
  emit(text.to_s, style: :green)
  emit_blank
  @last_block = :gap
end

#reset_finalize_geometryObject

Row-accurately erase the live region and reset its geometry to a clean blank top row BEFORE a finalize/interrupt/force-summary commit repaint (#421). The interrupt teardown (status_hide → clear_stream_region →status_stop) and the force-summary’s stream_end leave the composer’s recorded row geometry out of step with the physical rows — the status-row ticker painted a row #live_rows doesn’t track — so the final #print_above walks one row short and commits the live prompt into scrollback as a ghost ‘❯`, and the kept partial / whole summary block repaints twice. Routing through BottomComposer#finalize_region (the same geometry-reset seam Ctrl+L #395 / resize #401 use) makes the next commit land as ONE clean frame. A no-op when no composer owns the screen (plain TTY / pipe / tests / between turns); only terminal IO errors are swallowed (cosmetic).



1499
1500
1501
1502
1503
1504
1505
1506
# File 'lib/rubino/ui/cli.rb', line 1499

def reset_finalize_geometry
  composer = BottomComposer.current
  return unless composer

  composer.finalize_region
rescue IOError, Errno::EIO
  nil
end

#reveal_last_reasoningObject

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.



1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
# File 'lib/rubino/ui/cli.rb', line 1716

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

#ring_subagent_blocked(id, subagent) ⇒ Object

Rings ONLY the ⛔ attention bell/hook for a blocked child, WITHOUT the scrollback banner. Used by the mid-turn auto-open path: when the answer dropdown surfaces by itself, its own ‘◆ … asks` header + picker IS the on-screen banner, so re-printing #subagent_ask_banner above it just doubles the same question (#510). The attention bell still belongs on both paths — the subtree is parked on the human either way.



891
892
893
# File 'lib/rubino/ui/cli.rb', line 891

def ring_subagent_blocked(id, subagent)
  notifier.blocked("#{id} (#{subagent}) is waiting on your answer")
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.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'lib/rubino/ui/cli.rb', line 314

def select(prompt, choices)
  return nil if choices.nil? || choices.empty?

  # BACKGROUND subagent: the only #select a nested child reaches is the
  # Loop's budget-extension prompt at the tool-iteration ceiling (#574). With
  # a wired budget handler, surface it as a budget REQUEST on the card and
  # park the child on the same per-entry gate the approval path uses; the
  # handler maps the human's grant/deny to the Loop's :continue / :summarize
  # contract. Without one (nil), the child can't park → nil, which the Loop
  # reads as force-summarize (the headless guarantee), like UI::Null.
  return @budget_handler.call(prompt) if @budget_handler
  return nil unless interactive_terminal?

  BottomComposer.run_in_terminal do
    cancellable_prompt.select(prompt, cycle: false, filter: true) do |menu|
      menu.help(FILTER_MENU_HELP)
      choices.each { |label, value| menu.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

#separatorObject



551
552
553
# File 'lib/rubino/ui/cli.rb', line 551

def separator
  emit("" * 80, style: :dim)
end

#session_scope_noun(tool) ⇒ Object

How the session-scope option reads for a given tool: a batch of edits is “all edits”, writes “all writes”, shell “all shell commands”; anything else falls back to “this tool”. Kept in sync with #approval_choice’s :always_tool label.



535
536
537
538
539
540
541
542
543
# File 'lib/rubino/ui/cli.rb', line 535

def session_scope_noun(tool)
  case tool.to_s
  when "edit", "multi_edit" then "all edits"
  when "write"              then "all writes"
  when "shell"              then "all shell commands"
  when "", nil              then "this tool"
  else                           "all #{tool} calls"
  end
end

#session_scope_tip(tool, batch: false) ⇒ Object

One dim line per session pointing at the session-scope menu option so a user stops hand-approving every edit (#110, F4). Re-armed once when a BATCH is detected (batch: the 2nd same-tool “Approve once” in a turn) so a bulk refactor that’s already underway gets a louder nudge even if the user dismissed the opening tip. Tool-aware wording: an edit/write batch reads “all edits”/“all writes”, which is what the user actually wants to wave through — not the abstract “this tool”.



517
518
519
520
521
522
523
524
525
526
527
528
529
# File 'lib/rubino/ui/cli.rb', line 517

def session_scope_tip(tool, batch: false)
  return if @session_scope_tip_shown && !batch
  return if batch && @session_batch_tip_shown

  @session_scope_tip_shown = true
  @session_batch_tip_shown = true if batch
  noun = session_scope_noun(tool)
  lead = batch ? "bulk edit detected" : "tip"
  emit(
    %(#{lead}: choose "Approve — #{noun} (this session)" to approve #{noun} for the rest of this session ┄),
    style: :dim
  )
end

#set_subagent_cardsObject

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.



957
958
959
960
961
962
963
964
965
# File 'lib/rubino/ui/cli.rb', line 957

def set_subagent_cards
  composer = BottomComposer.current
  return unless composer

  entries = Tools::BackgroundTasks.instance.running
  composer.set_cards(subagent_cards.card_lines(entries), origin: @agent_id)
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.



1411
1412
1413
# File 'lib/rubino/ui/cli.rb', line 1411

def stash_probe_draft(text)
  @probe_draft = text
end

#status_back_to_thinkingObject

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.



1680
1681
1682
1683
1684
1685
1686
1687
1688
# File 'lib/rubino/ui/cli.rb', line 1680

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



1207
1208
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
# File 'lib/rubino/ui/cli.rb', line 1207

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 handled by #handle_thinking_delta: ALWAYS buffered
  # (for the collapse cue / ctrl-o reveal), and in :full ALSO streamed live
  # as a dim aside; :collapsed/:hidden just keep the spinner animating.
  if type == :thinking
    handle_thinking_delta(text)
    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

  # A content delta arriving while the turn is being interrupted (the
  # adapter's final think-filter flush on its way out of a cancelled
  # stream) is dropped: re-opening a stream here would paint a fresh raw
  # live tail under the already-committed partial block — the #265 ghost.
  # The partial the user already saw was committed by #finalize_stream.
  return if @turn_interrupting

  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
    # Label the (hidden) status row for the stall watchdog (#21): if this
    # block goes silent mid-stream, the resurfaced facet row reads "writing".
    relabel_streaming(type)
  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).



1299
1300
1301
1302
1303
1304
1305
1306
1307
# File 'lib/rubino/ui/cli.rb', line 1299

def stream_block_end(_message_id = nil)
  return unless @stream_type

  stream_end
  return unless @turn_active && thinking_painter

  @thinking_indicator = true
  status_ensure("thinking", phase: :thinking)
end

#stream_endObject



1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
# File 'lib/rubino/ui/cli.rb', line 1276

def stream_end
  clear_thinking_indicator
  if @stream_type == :content && @stream_md
    flush_content_stream
  elsif @stream_type
    emit_blank
  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_approval_choiceObject

The subagent shell-approval choice, rendered with the SAME arrow-key component as the main-agent menu (TUI-6 — replaces the old flat ‘[o]nce/[a]lways/o deny` line a non-decision keystroke silently denied). PUBLIC: the /agents handler (Handlers::Agents) calls it to present a parked child’s approval through the unified menu. Four named options matching the maintainer decision; returns one of :once, :always_command, :no, :deny_explain (or nil on an aborted read, which the caller treats as “re-prompt”, never a deny). The “Deny & tell the agent why” path lets the human hand the child a reason instead of a bare deny. The security semantics are unchanged — only the UI unifies.



463
464
465
466
467
468
469
470
# File 'lib/rubino/ui/cli.rb', line 463

def subagent_approval_choice
  approval_menu("approve?", [
                  ["Approve once", :once],
                  ["Approve always (this command)", :always_command],
                  ["Deny", :no],
                  ["Deny & tell the agent why", :deny_explain]
                ])
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.



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

def subagent_ask_banner(id, subagent, question)
  emit_blank
  emit("┄ a subagent needs you ┄", style: :dim)
  # id/subagent/question are untrusted — the funnel's PATH 1 (#emit) strips
  # every escape before the trusted style wrap (R3C-1, CWE-150).
  emit("#{id} (#{subagent}) is BLOCKED, waiting on your answer", style: %i[red bold])
  emit("#{question}", style: :yellow)
  emit("   everything it needs is paused until you answer — #{ask_timeout_hint}", style: :dim)
  emit("   → /reply #{id} <answer>   to answer   ·   /agents #{id} --stop   to cancel", style: :dim)
  $stdout.flush
  # The ⛔ state is the loudest one — the whole subtree is parked on the
  # human — so it also rings the attention bell/hook.
  ring_subagent_blocked(id, subagent)
end

#subagent_budget_choiceObject

The arrow-key picker for a subagent’s BUDGET request (#574): it hit its tool-iteration ceiling and is asking for more. Reuses the same unified #approval_menu component, but the vocabulary is GRANT/DENY budget — there is no “always” (no command to allowlist; budget is a one-shot grant). The caller maps :grant→continue and :deny/:summarize→summarize.



477
478
479
480
481
482
# File 'lib/rubino/ui/cli.rb', line 477

def subagent_budget_choice
  approval_menu("grant more budget?", [
                  ["Grant more iterations", :grant],
                  ["Summarize now", :summarize]
                ])
end

#subagent_cardsObject



977
978
979
# File 'lib/rubino/ui/cli.rb', line 977

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.



810
811
812
813
814
815
816
817
# File 'lib/rubino/ui/cli.rb', line 810

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

MINIMAL main-timeline lifecycle marker (agent-multiplexer Slice 1): just the close line (‘✓ <name> · done` / `✗ <name> · failed`) — dim, red only on failure. NO result summary or report is dumped into the main scrollback; the child’s per-tool detail lives in the BackgroundTasks registry (the card / /agents drill-in) and its full result reaches the MODEL via the InputQueue completion notice. The ‘report` param is kept in the signature for back-compat but no longer rendered here.



826
827
828
829
830
831
832
833
834
835
836
837
# File 'lib/rubino/ui/cli.rb', line 826

def subagent_lifecycle(line, status: "done", report: nil, id: nil)
  # The line embeds the subagent name (UNTRUSTED, R3C-1 / CWE-150): defang
  # every escape BEFORE the trusted style wrap. This is an ASYNC write from
  # the worker thread — the `✓ … done` completion notice can fire while an
  # approval modal owns the terminal (Y4), so it MUST go through the
  # committed parked-paint (see #note / #commit_async_above) and land at
  # column 0 on resume rather than at the cursor's offset over the modal.
  safe   = Util::Output.sanitize_terminal(line.to_s)
  styled = @pastel.decorate(safe, status == "failed" ? :red : :dim)
  commit_async_above([styled], gap: @last_block != :gap)
  @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.



702
703
704
# File 'lib/rubino/ui/cli.rb', line 702

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



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/rubino/ui/cli.rb', line 143

def table(headers:, rows:)
  # Row cells carry UNTRUSTED text — MCP tool/server names (/mcp), memory
  # content (/memory), session/agent titles. A raw `\e[…` there would
  # drive the terminal straight out of the grid (R3C-1, CWE-150), and it
  # would also corrupt TTY::Table's width math / the card layout. Sanitize
  # every cell to caret notation HERE — the single chokepoint both the
  # grid and the card paths flow through — before any width measurement.
  # Headers are rubino's own fixed labels but cost nothing to clean too.
  #
  # Keep TRUSTED SGR colour escapes in the cell (FRICTION-3): a status
  # cell like the /agents "● approval" is rubino's OWN pastel styling, and
  # the plain caret-notation sanitizer turned its `\e[33m…\e[0m` into a
  # visible `^[[33m●^[[0m` inside the grid. sanitize_terminal_keep_sgr
  # preserves the (inert, zero-width) colour while still neutralizing
  # every cursor-move / clear-screen / OSC byte. Width math below measures
  # on the SGR-STRIPPED text so the columns line up.
  rows = rows.map { |row| Array(row).map { |cell| Util::Output.sanitize_terminal_keep_sgr(cell.to_s) } }
  if grid_overflows?(headers, rows)
    render_cards(headers, rows)
  elsif rows.any? { |row| row.any? { |cell| cell.match?(Util::Output::SGR_RE) } }
    # TTY::Table measures column width on the RAW string and counts SGR
    # escape bytes as visible columns, so a colored cell padded the grid
    # crooked. When any cell carries colour, draw the unicode grid
    # ourselves on the display (SGR-stripped) width so colour renders AND
    # the box stays aligned.
    render_unicode_grid(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).
    # Cells are already SGR-sanitized above; PATH 2 (#emit_styled) keeps the
    # trusted cell colour while stripping any residual danger byte.
    emit_styled(tbl.render(:unicode, padding: [0, 1], width: terminal_cols, resize: false))
  end
end

#take_probe_draftObject

Consumes the parked /probe draft (see #stash_probe_draft), or nil.



1416
1417
1418
1419
1420
# File 'lib/rubino/ui/cli.rb', line 1416

def take_probe_draft
  draft = @probe_draft
  @probe_draft = nil
  draft
end

#terminal_colsObject

Terminal column count, headless-safe (falls back to 80).



241
242
243
244
245
246
247
248
# File 'lib/rubino/ui/cli.rb', line 241

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 


1802
1803
1804
1805
1806
# File 'lib/rubino/ui/cli.rb', line 1802

def think_changed(effort, previous: nil)
  arrow = previous && previous != effort ? "#{previous}#{effort}" : effort.to_s
  emit_blank
  emit("┄ effort #{arrow}", style: :dim)
end

#think_status(effort) ⇒ Object

‘/think` with no arg: confirm the current effort in house style.

┄ effort: medium ┄


1795
1796
1797
1798
# File 'lib/rubino/ui/cli.rb', line 1795

def think_status(effort)
  emit_blank
  emit("┄ effort: #{effort}", style: :dim)
end

#thinking_elapsed_secondsObject

Whole seconds the current/last thinking phase ran, for the collapse cue.



1536
1537
1538
1539
1540
# File 'lib/rubino/ui/cli.rb', line 1536

def thinking_elapsed_seconds
  return 0 unless @thinking_started_at

  (monotonic_now - @thinking_started_at).to_i
end

#thinking_finishedObject

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.



1402
1403
1404
1405
# File 'lib/rubino/ui/cli.rb', line 1402

def thinking_finished
  clear_thinking_indicator
  status_stop unless @turn_active
end

#thinking_painterObject

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



1431
1432
1433
1434
1435
# File 'lib/rubino/ui/cli.rb', line 1431

def thinking_painter
  return unless $stdout.respond_to?(:live) || BottomComposer.current || tty_stdout?

  method(:paint_live)
end

#thinking_startedObject

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.



1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
# File 'lib/rubino/ui/cli.rb', line 1380

def thinking_started
  return if @stream_type

  @thinking_started_at ||= monotonic_now
  unless thinking_painter
    return if @thinking_indicator

    @thinking_indicator = true
    # rubino's OWN dim label, no untrusted text → Cat 4 cursor-control
    # frame (transient print+flush, no committing newline).
    emit_frame(@pastel.dim("thinking…"))
    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).



1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
# File 'lib/rubino/ui/cli.rb', line 1589

def tool_body(text, kind: :plain)
  return if text.nil? || text.to_s.empty?

  # A diff is shown IN FULL (no collapse): the +/- hunks ARE the answer
  # when the user asked to see the diff (G3); collapsing them to 3 lines
  # defeats the point. Plain output keeps the head-N-lines preview.
  if kind == :diff
    write_body_lines(text.to_s) { |chomped| diff_line_color(chomped) }
    @last_block = :tool
    return
  end

  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) { |chomped| @pastel.dim(chomped) }
  emit("  #{hidden_lines_marker(hidden)}", style: :dim) if hidden.positive?
  @last_block = :tool
end

#tool_chunk(_name, chunk, kind: :plain) ⇒ 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.



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

def tool_chunk(_name, chunk, kind: :plain)
  record_subagent_tool_output(chunk)
  return if chunk.nil? || chunk.to_s.empty?

  # A diff the user asked to SEE (`git diff`, `git show`): colorize the
  # hunks and DON'T collapse to the 3-line preview — a code review wants
  # the full +/- (G3). Plain output keeps the head-N-lines collapse.
  if kind == :diff
    write_body_lines(chunk.to_s) { |chomped| diff_line_color(chomped) }
    @last_block = :tool
    return
  end

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



1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
# File 'lib/rubino/ui/cli.rb', line 1660

def tool_finished(name, result: nil)
  record_subagent_tool_finished(name, result)
  return delegation_finished(result) if name == "task"
  return status_back_to_thinking if result.respond_to?(:transcript_card?) && !result.transcript_card?

  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, call_id: 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.



1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
# File 'lib/rubino/ui/cli.rb', line 1572

def tool_started(name, arguments: nil, at: nil, call_id: nil)
  record_subagent_tool_started(name, arguments)
  finalize_stream
  return delegation_started(arguments, call_id) 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).

Returns:

  • (Boolean)


1509
1510
1511
1512
1513
# File 'lib/rubino/ui/cli.rb', line 1509

def tty_stdout?
  $stdout.respond_to?(:tty?) && $stdout.tty?
rescue StandardError
  false
end

#turn_finishedObject

Marks the end of a TURN (normal completion, error, or interrupt): the one place the turn-scoped ticker thread is allowed to die.



1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
# File 'lib/rubino/ui/cli.rb', line 1355

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

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 ┄


797
798
799
800
801
802
803
# File 'lib/rubino/ui/cli.rb', line 797

def turn_footer(text)
  pending = Array(@pending_subagent_footers)
  @pending_subagent_footers = nil
  line = ([text] + pending.map { |p| p[:fold] }).join(" · ")
  emit("#{line}", style: :dim)
  @last_block = :other
end

#turn_interruptedObject

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



716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
# File 'lib/rubino/ui/cli.rb', line 716

def turn_interrupted
  # Latch the interrupt FIRST: a late content delta (the adapter flushes
  # its think-filter tail on the way out of an interrupted stream) must
  # NOT re-open a fresh stream and paint a new raw live tail UNDER the
  # block #finalize_stream just committed — that stray rolling-tail row is
  # the #265 ghost on the interrupt path. While latched, #stream drops
  # content deltas (they can no longer reach the user anyway) so nothing
  # re-arms the live region after it has been torn down.
  @turn_interrupting = true
  finalize_stream
  # Tear down the WHOLE painted live tail, not just the bounded
  # LIVE_TAIL_ROWS window: any raw rolling-tail rows still on screen (a
  # tail painted by a delta that landed in the cancel race, before the
  # latch) are cleared through the live region's row-accurate erase so no
  # raw/duplicated fragment survives above `⎿ interrupted` (#265).
  clear_stream_region
  # Interrupt = turn end for the status row: kill the engine thread.
  status_stop
  @thinking_indicator = false
  if @suppress_interrupt_marker
    @suppress_interrupt_marker = false
    @turn_interrupting = false
    # Even the QUIET (#111) path reset the region: the thinking-row teardown
    # above (status_hide/stop) desynced the geometry, so the NEXT committed
    # line would otherwise inherit the ghost (#421).
    reset_finalize_geometry
    return
  end

  # Reset the live-region geometry through the composer BEFORE the final
  # `⎿ interrupted` commit (#421): the thinking-row + live-tail teardown
  # above left @rows_above out of step with the physical rows, so without
  # this the marker's #print_above walks one row short, commits the live
  # prompt as a ghost `❯` above the marker, and repaints the kept partial
  # twice. The reset makes the marker land as ONE clean frame.
  reset_finalize_geometry
  clear_line
  emit("  ⎿ interrupted", style: :dim)
  $stdout.flush
  @turn_interrupting = false
end

#turn_startedObject

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.



1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
# File 'lib/rubino/ui/cli.rb', line 1327

def turn_started
  @turn_active     = true
  @turn_started_at = monotonic_now
  @turn_tool_count = 0
  @turn_tok_chars  = 0
  # Fresh turn: silence clock unarmed (#21).
  @status_mutex.synchronize { @last_stream_at = nil }
  # Per-turn tally of plain "Approve once" choices by tool — drives the
  # bulk-refactor batch nudge (F4); reset each turn so a new refactor
  # re-detects its batch.
  @turn_once_by_tool = nil
  # The FIRST status of a turn is "waiting for model…", not "thinking":
  # before the first byte arrives there's a multi-second network/model
  # round-trip with nothing happening locally (F5). A distinct label makes
  # that gap read as model latency, not a frozen client. The first stream
  # delta / reasoning / tool relabels it to "thinking" — every one of those
  # paths already calls status_ensure/status_show, so the transition is
  # automatic; we only seed a different opening label here.
  @thinking_indicator = true if thinking_painter
  status_show(MODEL_WAIT_LABEL, phase: :thinking)
end

#with_spinner(message, &block) ⇒ Object



1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
# File 'lib/rubino/ui/cli.rb', line 1847

def with_spinner(message, &block)
  spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
  spinner.auto_spin
  result = block.call
  spinner.success
  result
rescue StandardError => e
  spinner.error
  raise e
end