Class: Rubino::UI::CLI
- Inherits:
-
PrinterBase
- Object
- Base
- PrinterBase
- Rubino::UI::CLI
- Defined in:
- lib/rubino/ui/cli.rb
Overview
Terminal-based UI adapter using TTY gems.
All output goes to stdout via plain prints — no alt-screen, no mouse capture, no cursor positioning. Native terminal scroll, copy, and shell history all keep working because we never leave the main screen.
Extends PrinterBase; uses compact append-only timeline rendering (no boxes, no per-element timestamps, no horizontal rules). Visual language:
● active tool or activity
✓ completed successfully
✗ failed
◆ approval required
┄ low-priority metadata
Constant Summary collapse
- PICKER_PAGE_SIZE =
Page size tty-prompt paginates a select menu at (its Paginator's DEFAULT_PAGE_SIZE) — the count of menu rows visible at once, used to wipe a cancelled picker's frame (#219).
6- 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…"- LIVE_FRAME_HZ =
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).Transient live repaints are capped to this many per second (coalesced). ~20 fps is smooth to the eye but turns a fast model's hundreds-of-deltas- per-second repaint storm into at most 20 frames/s of terminal output — the fix for the streaming "freeze" (the terminal, not rubino, was the bottleneck). The trailing frame is flushed by the ticker (#flush_pending_live).
20.0- LIVE_FRAME_INTERVAL =
1.0 / LIVE_FRAME_HZ
- JOB_STATUS_LABELS =
Short human labels for the post-turn inline jobs the status row tracks.
{ "ExtractMemoryJob" => "memory", "DistillSkillJob" => "skills" }.freeze
Instance Attribute Summary collapse
-
#live_status_provider ⇒ Object
writeonly
The REPL host installs a per-turn render lambda here so the
ctxgauge climbs live during a turn (#608e); see @live_status_provider.
Instance Method Summary collapse
-
#activity_finished(name, metric: nil, failed: false) ⇒ Object
Activity finished.
-
#activity_started(name, hint: nil) ⇒ Object
Activity started: renders as
● nameor● name hint— a QUIET dim row with only the ● in cyan. -
#answer_gap ⇒ Object
Exactly ONE blank line before the answer payload (P3) — skipped when the previous committed block already left a gap open.
- #ask(prompt) ⇒ Object
-
#assistant_text(text) ⇒ Object
Markdown rendering: assistant output rendered as readable text with modest indentation, no box.
-
#body(text) ⇒ Object
Body text rendered with modest indentation (no big box).
- #box_close(*_pieces, color: nil) ⇒ Object
-
#box_open(*pieces, at: nil, color: nil) ⇒ Object
--- Legacy box methods (used by print_session_history replay) ---.
-
#branch_confirmation(new_id:, parent_id:, title:, included_probe:) ⇒ Object
Confirms a
/branchfork in the dim block from the locked UX: the new session id + title, the parent it inherits from, and the literal way back (/sessions <parent>), bracketed by┄ branched ┄/┄ now in <id> ┄rails. -
#cancellable_prompt ⇒ Object
A DEDICATED TTY::Prompt for cancellable pickers, with Esc bound to the same InputInterrupt Ctrl-C raises (#73): tty-reader parses full escape sequences, so arrows (ESC [ A…) never trip :keyescape — only a lone Esc does. Deliberately separate from the shared @prompt so the approval menu's keymap is untouched (an Esc there must not become a deny)..
-
#clear_line ⇒ Object
In-place clear of the current row (CR + erase-line) before a committed line lands.
-
#clear_stream_region ⇒ Object
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.
-
#code_highlight? ⇒ Boolean
display.code_highlight — opt-in syntax highlighting of committed code blocks (default false).
-
#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).
-
#commit_markdown_block(text) ⇒ Object
Renders a markdown string to committed, styled lines above the composer (each line as
$stdout.puts "#{MD_MARGIN}#{line}"). - #compression_finished(metadata, at: nil) ⇒ Object
- #compression_started(at: nil) ⇒ Object
-
#confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) ⇒ Boolean
Approval prompt with session memory.
-
#confirm_destructive(question) ⇒ Object
A destructive yes/No confirm — NOT the tool-approval menu (#218).
-
#denied(tool = nil) ⇒ Object
Explicit, visible confirmation that a denied command was NOT executed.
-
#diff_line_color(line) ⇒ Object
+/-/@@ unified-diff coloring shared by streamed diff chunks (#tool_chunk) and the end-of-call diff body (#tool_body).
-
#display_width(str) ⇒ Object
Terminal columns a string occupies.
- #emit_live_frame(frame) ⇒ Object
-
#erase_picker_frame(choice_count) ⇒ Object
Clears a cancelled picker's drawn frame: 1 header row + the visible menu rows (tty-prompt paginates at PICKER_PAGE_SIZE).
-
#error(message) ⇒ Object
A turn that ends in ERROR must tear down the live "thinking…" animation (and any open stream) BEFORE the error line prints — otherwise the ticking row strands below the error and keeps interleaving into every subsequent print until a full repaint (#74).
-
#flush_pending_live ⇒ Object
Paint the latest coalesced frame if the interval has elapsed.
- #grid_border(widths, left, mid, right) ⇒ Object
-
#grid_overflows?(headers, rows) ⇒ Boolean
True when the natural grid width (column maxima + unicode borders + padding) won't fit the terminal.
- #grid_row(cells, widths) ⇒ Object
-
#handle_thinking_delta(text) ⇒ Object
A reasoning delta.
-
#hint_row(command, description) ⇒ Object
Welcome-panel hint row (P8): the actionable command is the ONE cyan accent; its description stays plain.
-
#initialize(session_id: nil, approval_cache: nil, agent_id: :main, approval_handler: nil, budget_handler: nil) ⇒ CLI
constructor
A new instance of CLI.
-
#input_injected(text) ⇒ Object
Confirms text the loop picked up mid-turn and injected into the CURRENT turn (Phase-2 steering).
-
#interactive? ⇒ 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).
-
#interactive_terminal? ⇒ Boolean
True when both ends are a real interactive terminal — the shared gate for every interactive prompt/menu (#ask / #select): off a TTY they return nil instead of rendering ANSI into a pipe.
- #job_enqueued(type) ⇒ Object
- #job_finished(type) ⇒ Object
-
#job_started(type) ⇒ Object
Post-turn inline jobs (P6): the aux-LLM memory extract / skill distill used to freeze the UI for seconds after the footer.
- #job_status_label(type) ⇒ Object
-
#markdown_width ⇒ Object
Column budget for markdown rendering: terminal width minus the MD_MARGIN indent applied to every committed line.
- #mode_changed(name, previous: nil) ⇒ Object
- #monotonic_now ⇒ Object
-
#note(text) ⇒ Object
Free-line annotation rendered as
┄ message ┄, dim. -
#notifier ⇒ Object
The attention notifier (terminal bell + optional command hook).
-
#paint_live(frame) ⇒ Object
Coalescing front door for the transient live region (tail / partial table / reasoning aside).
-
#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.
-
#panel_line(label, value, pointer: nil) ⇒ Object
Panel color diet (P8): dim label, PLAIN value, cyan reserved for the actionable pointer (
(use /mcp)). -
#probe_aside(answer) ⇒ Object
Renders an ephemeral
probeanswer 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. -
#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). -
#queued(text) ⇒ Object
Echoes a line the user typed mid-turn, parked for the next turn.
-
#reasoning_changed(mode, previous: nil) ⇒ Object
/reasoning <mode>: confirm the session render-mode switch. -
#reasoning_mode ⇒ Object
The active reasoning render mode (:hidden | :collapsed | :full), resolved from config (which /reasoning writes to, so the adapter gate and this render path share one source of truth).
-
#reasoning_status(mode) ⇒ Object
/reasoningwith no arg: confirm the current render mode in house style. -
#redisplay_idle_prompt ⇒ Object
Ask Reline to repaint its prompt + current buffer after out-of-band output (the Ctrl+O reveal) has scrolled below the parked idle prompt.
-
#refresh_live_cards ⇒ Object
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.
-
#refresh_live_ctx_bar ⇒ Object
Repaints the persistent status bar with the live in-flight token estimate so
ctx ~Xk/…climbs during a turn (#608e). -
#render_cards(headers, rows) ⇒ Object
Vertical key/value cards:
Label value, labels padded to a common width, a dim rule between records. -
#render_markdown_block(text, highlight: false) ⇒ Object
A markdown string -> Array
of ANSI-styled lines (no indent). -
#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.
-
#replay_user_input(text, at: nil) ⇒ Object
Replay user input in compact form.
-
#reset_finalize_geometry ⇒ Object
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).
-
#reveal_last_reasoning ⇒ Object
Ctrl+O reveal: re-render the LAST retained reasoning buffer as the full-style
┊aside, committed into scrollback NOW (append-only — a scrollback terminal can't un-print the committed cue, so this is a one-way reveal of the retained buffer, not a hide-toggle). -
#select(prompt, choices) ⇒ Object
Arrow-key single-select menu — the SAME TTY::Prompt component the tool approval menu uses (see #approval_choice), so /sessions resume reuses the existing picker rather than introducing a second menu system (#145).
- #separator ⇒ Object
-
#session_scope_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".
-
#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).
-
#set_subagent_cards ⇒ Object
Repaints the SUBAGENT CARD block in the live region from the BackgroundTasks registry (Variant A).
-
#stash_probe_draft(text) ⇒ Object
Holds text the user typed during a synchronous /probe wait (#221), so the next idle prompt seeds it back into
❯— the wait owns a transient composer to echo input, but it's torn down before the REPL reopens its idle composer, so the buffer is parked here in between. -
#status_back_to_thinking ⇒ Object
After a tool's
└ ✓close row commits, swap the status row back to the thinking phase (the P4 inter-tool gap) with the accumulated stats. -
#stream(chunk) ⇒ Object
--- Streaming (unchanged except visual, now uses assistant_text) ---.
-
#stream_block_end(_message_id = nil) ⇒ Object
Block boundary on the STREAMING path, driven by the adapter's after_message callback (one assistant message == one content block; on a multi-step tool turn several blocks stream within one model call).
- #stream_end ⇒ Object
-
#subagent_approval_choice ⇒ Object
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/[n]o denyline a non-decision keystroke silently denied). -
#subagent_budget_choice ⇒ Object
The arrow-key picker for a subagent's BUDGET request (#574): it hit its tool-iteration ceiling and is asking for more.
- #subagent_cards ⇒ Object
-
#subagent_finished(line, id: nil, status: "done", report: nil) ⇒ Object
A background subagent reached a terminal state.
-
#subagent_lifecycle(line, status: "done", report: nil, id: nil) ⇒ Object
MINIMAL main-timeline lifecycle marker (agent-multiplexer Slice 1): just the close line (
✓ <name> · done/✗ <name> · failed) — dim, red only on failure. -
#suppress_interrupt_marker(value: true) ⇒ Object
One-shot suppression of the next
⎿ interruptedmarker (#111). -
#table(headers:, rows:) ⇒ Object
Renders a table, degrading to a readable vertical card layout when the full grid would overflow a narrow terminal (#84).
-
#take_probe_draft ⇒ Object
Consumes the parked /probe draft (see #stash_probe_draft), or nil.
-
#terminal_cols ⇒ Object
Terminal column count, headless-safe (falls back to 80).
-
#think_changed(effort, previous: nil) ⇒ Object
/think <level>: confirm the effort switch. -
#think_status(effort) ⇒ Object
/thinkwith no arg: confirm the current effort in house style. -
#thinking_elapsed_seconds ⇒ Object
Whole seconds the current/last thinking phase ran, for the collapse cue.
-
#thinking_finished ⇒ Object
Clears the status row for callers that bracket a synchronous wait with no stream lifecycle of their own — the /probe side-inference (#58).
-
#thinking_painter ⇒ Object
The per-frame paint strategy for the thinking animation, or nil when the output can't host one (a pipe with no composer).
-
#thinking_started ⇒ Object
Shows the status row during the model wait.
-
#throttle_live? ⇒ Boolean
Coalesce live repaints only while the turn ticker thread is alive — it provides the trailing-edge flush (#flush_pending_live).
-
#tool_body(text, kind: :plain) ⇒ Object
DISPLAY-ONLY collapse (P2): the transcript shows the head few lines of a tool's output plus a
… +N lines (full output → context)marker — the FULL output still goes to the model/context unchanged. -
#tool_chunk(_name, chunk, kind: :plain, full: false) ⇒ Object
Streamed tool output (shell): same display-only collapse as #tool_body, accumulated across chunks.
-
#tool_finished(name, result: nil) ⇒ Object
Tool finished renders as the compact
└ ✓ metricclose row, or└ ✗ failed · name · errorin red (P10). -
#tool_params_begin(name) ⇒ Object
The streaming tool call just started (its NAME arrived, #608).
-
#tool_params_feed(fragment) ⇒ Object
An argument fragment of the in-flight call: decode the JSON arg VALUES and render the complete lines unlocked so far under the card (the partial last line is held by the decoder until its newline lands), reusing the head-N-lines preview collapse.
-
#tool_params_flush ⇒ Object
Flush the held params tail and close the streaming-params state.
-
#tool_started(name, arguments: nil, at: nil, call_id: nil) ⇒ Object
Tool started renders as the quiet
● name hintopen row (P1). -
#tty_stdout? ⇒ Boolean
True when $stdout is a real terminal (guarded for IO doubles).
-
#turn_finished ⇒ Object
Marks the end of a TURN (normal completion, error, or interrupt): the one place the turn-scoped ticker thread is allowed to die.
-
#turn_footer(text) ⇒ Object
The STATIC turn footer rail, all dim:
┄ turn · 16.6s · 3 tools ┄. -
#turn_interrupted ⇒ Object
Commits the standardized interrupt marker right after the partial answer that was kept when a turn is cancelled (Ctrl+C, or the interrupt-by- default Enter): a dim
⎿ interruptedrow, house grammar. -
#turn_started ⇒ Object
Marks the start of a TURN: resets the per-turn stats and starts the status-row engine in its initial "thinking" phase (the P1 wait).
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.
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 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/rubino/ui/cli.rb', line 72 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 # Optional cheap render lambda (set by the REPL host per turn) that maps a # LIVE in-flight token estimate to a status-bar line, so the `ctx ~Xk/…` # gauge climbs DURING a turn instead of freezing until it ends (#608e). # nil off the interactive REPL (one-shot/API/tests) — the bar is unchanged. @live_status_provider = nil @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 # Transient live-region repaint COALESCING (#freeze): a fast model streams # deltas hundreds of times a second and each one repainted the live tail # in full (clear + redraw, wrapped in synchronized-output) — rubino's CPU # stays low but the volume of cursor-churn ANSI floods the terminal, which # is what reads as a "freeze" (worst in the :full reasoning aside). We cap # transient repaints to ~LIVE_FRAME_HZ: a too-soon frame is stored as # @live_pending and the LATEST one is flushed by the ticker thread (≤ one # STATUS_TICK later) or the next delta. Clearing frames bypass the cap so # the tail tears down instantly on commit/turn-end. Guarded by its own # mutex (paint comes from the delta thread AND the ticker thread). @live_mutex = Mutex.new @live_pending = nil # latest coalesced frame awaiting flush, or nil @live_painted_at = nil # monotonic time of the last actual emit # 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 Attribute Details
#live_status_provider=(value) ⇒ Object (writeonly)
The REPL host installs a per-turn render lambda here so the ctx gauge
climbs live during a turn (#608e); see @live_status_provider.
30 31 32 |
# File 'lib/rubino/ui/cli.rb', line 30 def live_status_provider=(value) @live_status_provider = value 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).
666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 |
# File 'lib/rubino/ui/cli.rb', line 666 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).
631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 |
# File 'lib/rubino/ui/cli.rb', line 631 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_gap ⇒ Object
Exactly ONE blank line before the answer payload (P3) — skipped when the previous committed block already left a gap open. No trailing blank: the turn footer attaches directly under the answer. Shared by the non-streamed (#assistant_text) and streamed (#stream) paths so both turns read identically.
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.
1077 1078 1079 1080 |
# File 'lib/rubino/ui/cli.rb', line 1077 def answer_gap commit_block_atomic([""]) unless @last_block == :gap @last_block = :answer end |
#ask(prompt) ⇒ Object
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/rubino/ui/cli.rb', line 280 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 MULTI-LINE prompt (the `question` tool builds question + numbered # options + a final "Your choice:" line) handed WHOLE to TTY::Prompt#ask # corrupts the screen: the instant the typed answer wraps the input row, # TTY::Prompt's redraw over-counts rows and clears lines ABOVE the prompt, # erasing the conversation scrollback. Fix: emit the prompt BODY (every # line but the last) as committed output so it lands in scrollback and # stays put, and hand TTY::Prompt only the SHORT final line. The API UI # (UI::API#ask) still gets the full multi-line prompt for its clarify # event — this split is CLI-only. lines = prompt.to_s.split("\n") ask_line = lines.pop.to_s lines.each { |line| emit(line) } # 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). # # BUG 01: while a turn streams, anything the user types is parked in the # type-ahead queue. A clarification/`question` opening mid-turn used to # read $stdin blind to that queue, so a line the user typed the instant # the prompt appeared fired as a stray NEW turn afterwards instead of # answering the prompt (Symptom C). Reconcile the seam: drain the pending # queue line + in-flight keystrokes and PREFILL them as the answer — the # user sees it and confirms/edits with Enter (never auto-submitted). BottomComposer.run_in_terminal_with_pending do |pending| pending && !pending.empty? ? @prompt.ask(ask_line, value: pending) : @prompt.ask(ask_line) end end |
#assistant_text(text) ⇒ Object
Markdown rendering: assistant output rendered as readable text with modest indentation, no box.
1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 |
# File 'lib/rubino/ui/cli.rb', line 1051 def assistant_text(text) return if text.nil? || text.to_s.empty? # A progress indicator must be REPLACED by its result, never left as # residue above the answer (#86). On the non-streaming path nothing # else clears the transient "thinking…" line before the committed # answer, so collapse any buffered reasoning + clear the animation first. collapse_reasoning answer_gap commit_markdown_block(text) end |
#body(text) ⇒ Object
Body text rendered with modest indentation (no big box).
716 717 718 719 720 721 722 |
# File 'lib/rubino/ui/cli.rb', line 716 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
1987 1988 1989 1990 |
# File 'lib/rubino/ui/cli.rb', line 1987 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) ---
1981 1982 1983 1984 1985 |
# File 'lib/rubino/ui/cli.rb', line 1981 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.
928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 |
# File 'lib/rubino/ui/cli.rb', line 928 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_prompt ⇒ Object
A DEDICATED TTY::Prompt for cancellable pickers, with Esc bound to the same InputInterrupt Ctrl-C raises (#73): tty-reader parses full escape sequences, so arrows (ESC [ A…) never trip :keyescape — only a lone Esc does. Deliberately separate from the shared @prompt so the approval menu's keymap is untouched (an Esc there must not become a deny).
411 412 413 414 415 |
# File 'lib/rubino/ui/cli.rb', line 411 def cancellable_prompt @cancellable_prompt ||= TTY::Prompt.new.tap do |picker| picker.on(:keyescape) { raise TTY::Reader::InputInterrupt } end end |
#clear_line ⇒ Object
In-place clear of the current row (CR + erase-line) before a committed
line lands. Purely a cursor-positioning nicety, so it is gated on a real
TTY: into a pipe there is no cursor and the raw \e[2K would leak as
literal bytes into the cooked output (#56).
1577 1578 1579 1580 1581 1582 1583 |
# File 'lib/rubino/ui/cli.rb', line 1577 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_region ⇒ Object
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.
807 808 809 810 811 812 813 814 815 816 |
# File 'lib/rubino/ui/cli.rb', line 807 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 |
#code_highlight? ⇒ Boolean
display.code_highlight — opt-in syntax highlighting of committed code blocks (default false).
1139 1140 1141 |
# File 'lib/rubino/ui/cli.rb', line 1139 def code_highlight? Rubino.configuration.display_code_highlight? 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.
894 895 896 897 898 899 900 901 902 903 904 |
# File 'lib/rubino/ui/cli.rb', line 894 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.
1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 |
# File 'lib/rubino/ui/cli.rb', line 1097 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. Emit the whole block in ONE write (lines joined by "\n", # which #emit_styled preserves) so it commits as a SINGLE frame instead of # one full live-region repaint per line — the #FREEZE storm on long blocks. lines = render_markdown_block(text).map { |line| "#{MD_MARGIN}#{line}" } emit_styled(lines.join("\n")) unless lines.empty? end |
#compression_finished(metadata, at: nil) ⇒ Object
1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 |
# File 'lib/rubino/ui/cli.rb', line 1828 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
1823 1824 1825 1826 |
# File 'lib/rubino/ui/cli.rb', line 1823 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
440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 |
# File 'lib/rubino/ui/cli.rb', line 440 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.
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 |
# File 'lib/rubino/ui/cli.rb', line 547 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.
602 603 604 605 |
# File 'lib/rubino/ui/cli.rb', line 602 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.
1780 1781 1782 1783 1784 1785 1786 1787 1788 |
# File 'lib/rubino/ui/cli.rb', line 1780 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.
276 277 278 |
# File 'lib/rubino/ui/cli.rb', line 276 def display_width(str) Unicode::DisplayWidth.of(str.to_s.gsub(Util::Output::SGR_RE, "")) end |
#emit_live_frame(frame) ⇒ Object
1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 |
# File 'lib/rubino/ui/cli.rb', line 1506 def emit_live_frame(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 |
#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.
399 400 401 402 403 404 |
# File 'lib/rubino/ui/cli.rb', line 399 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.
730 731 732 733 734 735 736 737 |
# File 'lib/rubino/ui/cli.rb', line 730 def error() finalize_stream # An error tears the turn-scoped status row down entirely (#74): the # next model attempt (retry/fallback) restarts it via thinking_started. status_stop @thinking_indicator = false super end |
#flush_pending_live ⇒ Object
Paint the latest coalesced frame if the interval has elapsed. Called by the ticker thread every STATUS_TICK so a burst that stops mid-stream still settles to its final tail within ~one tick (no stale rows during a pause). The actual terminal write happens OUTSIDE @live_mutex (its own seam mutex), mirroring #refresh_live_cards' un-nested-locks discipline.
1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 |
# File 'lib/rubino/ui/cli.rb', line 1493 def flush_pending_live emit = nil @live_mutex.synchronize do return if @live_pending.nil? return if @live_painted_at && (monotonic_now - @live_painted_at) < LIVE_FRAME_INTERVAL emit = @live_pending @live_pending = nil @live_painted_at = monotonic_now end emit_live_frame(emit) end |
#grid_border(widths, left, mid, right) ⇒ Object
221 222 223 |
# File 'lib/rubino/ui/cli.rb', line 221 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).
237 238 239 240 241 242 243 244 245 |
# File 'lib/rubino/ui/cli.rb', line 237 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
225 226 227 228 229 230 231 |
# File 'lib/rubino/ui/cli.rb', line 225 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.
1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 |
# File 'lib/rubino/ui/cli.rb', line 1254 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.
622 623 624 |
# File 'lib/rubino/ui/cli.rb', line 622 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).
1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 |
# File 'lib/rubino/ui/cli.rb', line 1029 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.
341 342 343 344 345 346 347 348 349 |
# File 'lib/rubino/ui/cli.rb', line 341 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.
328 329 330 331 332 333 334 |
# File 'lib/rubino/ui/cli.rb', line 328 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
1954 1955 1956 |
# File 'lib/rubino/ui/cli.rb', line 1954 def job_enqueued(type) puts_colored(:dim, " ⊕ Job enqueued: #{type}") if Rubino.configuration.ui_verbose? end |
#job_finished(type) ⇒ Object
1970 1971 1972 1973 |
# File 'lib/rubino/ui/cli.rb', line 1970 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 ·
1962 1963 1964 1965 1966 1967 1968 |
# File 'lib/rubino/ui/cli.rb', line 1962 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
1975 1976 1977 |
# File 'lib/rubino/ui/cli.rb', line 1975 def job_status_label(type) JOB_STATUS_LABELS[type.to_s] || type.to_s end |
#markdown_width ⇒ Object
Column budget for markdown rendering: terminal width minus the MD_MARGIN indent applied to every committed line. Headless-safe (falls back to 80).
winsize can under-report during the bottom-composer raw-mode TUI while a
table is still streaming, returning a tiny/zero column count (#95). Treat
any non-positive width as "unknown" and fall back to 80, and never let the
budget drop below MIN_MARKDOWN_WIDTH, so columns stay readable mid-stream.
1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 |
# File 'lib/rubino/ui/cli.rb', line 1162 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
1941 1942 1943 1944 1945 1946 |
# File 'lib/rubino/ui/cli.rb', line 1941 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_now ⇒ Object
1415 1416 1417 |
# File 'lib/rubino/ui/cli.rb', line 1415 def monotonic_now Process.clock_gettime(Process::CLOCK_MONOTONIC) end |
#note(text) ⇒ Object
Free-line annotation rendered as ┄ message ┄, dim.
819 820 821 822 823 824 825 826 827 828 829 830 831 832 |
# File 'lib/rubino/ui/cli.rb', line 819 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 |
#notifier ⇒ Object
The attention notifier (terminal bell + optional command hook). Public so the background-task plumbing can ring it when a child parks on an approval (TaskTool#approval_handler_for).
155 156 157 |
# File 'lib/rubino/ui/cli.rb', line 155 def notifier @notifier ||= Notifier.new end |
#paint_live(frame) ⇒ Object
Coalescing front door for the transient live region (tail / partial table / reasoning aside). A CLEARING frame (nil/empty — a commit/teardown) is painted immediately and cancels any pending frame, so the row never lingers. A content frame paints immediately if at least LIVE_FRAME_INTERVAL has passed since the last emit; otherwise it is stored as @live_pending and the LATEST stored frame is flushed by the ticker thread (#flush_pending_live) or superseded by the next delta — bounding repaints regardless of token rate.
1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 |
# File 'lib/rubino/ui/cli.rb', line 1456 def paint_live(frame) emit = false to_emit = nil @live_mutex.synchronize do clearing = frame.nil? || frame.to_s.empty? now = monotonic_now # Coalesce ONLY a content frame that arrives within the interval, and # ONLY while the ticker is running to flush the trailing one. A clearing # (teardown) frame is never withheld and RESETS the window so the next # content frame — often painted in the SAME delta right after a tail # clear — emits at once. if !clearing && throttle_live? && @live_painted_at && (now - @live_painted_at) < LIVE_FRAME_INTERVAL @live_pending = frame # ticker/next delta paints the latest else @live_pending = nil @live_painted_at = clearing ? nil : now to_emit = frame emit = true end end emit_live_frame(to_emit) if emit 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.
1537 1538 1539 1540 1541 1542 1543 |
# File 'lib/rubino/ui/cli.rb', line 1537 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.
614 615 616 617 618 |
# File 'lib/rubino/ui/cli.rb', line 614 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.
912 913 914 915 916 917 918 919 920 921 922 |
# File 'lib/rubino/ui/cli.rb', line 912 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.
697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 |
# File 'lib/rubino/ui/cli.rb', line 697 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.
1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 |
# File 'lib/rubino/ui/cli.rb', line 1000 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.
1916 1917 1918 1919 1920 1921 1922 1923 1924 |
# File 'lib/rubino/ui/cli.rb', line 1916 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_mode ⇒ Object
The active reasoning render mode (:hidden | :collapsed | :full), resolved from config (which /reasoning writes to, so the adapter gate and this render path share one source of truth). Handles the legacy show_reasoning back-compat mapping.
1589 1590 1591 |
# File 'lib/rubino/ui/cli.rb', line 1589 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 ┄
1904 1905 1906 1907 |
# File 'lib/rubino/ui/cli.rb', line 1904 def reasoning_status(mode) emit_blank emit("┄ reasoning: #{mode} ┄", style: :dim) end |
#redisplay_idle_prompt ⇒ Object
Ask Reline to repaint its prompt + current buffer after out-of-band output (the Ctrl+O reveal) has scrolled below the parked idle prompt. Uses the public Reline line-refresh seam; fully guarded so a Reline that lacks it (or a non-Reline input path) degrades to a no-op rather than crashing the prompt. Does NOT attempt to move the reveal above the prompt (that's the deferred pinned-layout work) — it only restores the prompt line so the cursor isn't left bare.
1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 |
# File 'lib/rubino/ui/cli.rb', line 1888 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_cards ⇒ Object
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.
970 971 972 973 974 |
# File 'lib/rubino/ui/cli.rb', line 970 def refresh_live_cards set_subagent_cards if Tools::BackgroundTasks.instance.running.any? rescue StandardError nil end |
#refresh_live_ctx_bar ⇒ Object
Repaints the persistent status bar with the live in-flight token estimate
so ctx ~Xk/… climbs during a turn (#608e). The provider (installed by
the REPL host for the duration of a turn) maps the estimate → a bar line;
@turn_tok_chars/4 is the SAME chars/4 estimate the facet's ~N tok uses.
No-op off the interactive REPL (provider nil) or between turns. Cosmetic:
a paint failure must never disturb the turn.
982 983 984 985 986 987 988 989 |
# File 'lib/rubino/ui/cli.rb', line 982 def return unless @turn_active && @live_status_provider line = @live_status_provider.call(@turn_tok_chars / 4) BottomComposer.current&.set_status(line) if line 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.
249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'lib/rubino/ui/cli.rb', line 249 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, highlight: false) ⇒ Object
A markdown string -> Array
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.
highlight: syntax-highlight fenced code blocks (Rouge). Passed true only
by the COMMITTED render paths — never the per-delta live tail — so
highlighting can never block the stream. Gated by display.code_highlight.
1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 |
# File 'lib/rubino/ui/cli.rb', line 1126 def render_markdown_block(text, highlight: false) text = Util::Output.sanitize_terminal(text) renderer = MarkdownRenderer.new(width: markdown_width, code_highlight: highlight && code_highlight?) renderer.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.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/rubino/ui/cli.rb', line 206 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.
1607 1608 1609 1610 1611 1612 1613 1614 |
# File 'lib/rubino/ui/cli.rb', line 1607 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_geometry ⇒ Object
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).
1557 1558 1559 1560 1561 1562 1563 1564 |
# File 'lib/rubino/ui/cli.rb', line 1557 def reset_finalize_geometry composer = BottomComposer.current return unless composer composer.finalize_region rescue IOError, Errno::EIO nil end |
#reveal_last_reasoning ⇒ Object
Ctrl+O reveal: re-render the LAST retained reasoning buffer as the
full-style ┊ aside, committed into scrollback NOW (append-only — a
scrollback terminal can't un-print the committed cue, so this is a
one-way reveal of the retained buffer, not a hide-toggle). A no-op when
nothing is retained (hidden mode, or no reasoning yet this session).
Wired as the BottomComposer's on_ctrl_o callback; prints through $stdout
so it lands above the prompt under the composer's render mutex.
1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 |
# File 'lib/rubino/ui/cli.rb', line 1849 def reveal_last_reasoning # NOTHING retained (hidden mode never buffered one, or — the common case # on providers that stream no thinking blocks at all — no reasoning ever # arrived): give the advertised key ONE dim line of feedback instead of # a forever-silent no-op that reads as a broken keybinding (#133). One # note per dry spell: further presses stay silent until reasoning is # actually retained (which resets the flag below). if @last_reasoning.nil? || @last_reasoning.strip.empty? unless @no_reasoning_note_shown @no_reasoning_note_shown = true note("no reasoning retained — this provider streamed no thinking blocks") end return end # IDEMPOTENT + SILENT: a scrollback aside can't be un-printed, so # revealing the SAME retained buffer twice would just stack an identical # block. Once this thought has been revealed, any further Ctrl+O is a # true silent no-op — we print NOTHING (no ack line), so a human mashing # Ctrl+O gets silence, not growing scrollback. #collapse_reasoning clears # the flag when a NEW thought is retained, so its first reveal works, and # a new turn resets it so its first reveal works again. return if @last_reasoning_revealed commit_reasoning_aside(@last_reasoning, @last_reasoning_seconds.to_i) @last_reasoning_revealed = true # Re-emit the idle prompt so the cursor returns to a proper prompt line # instead of being stranded on a bare line below the reveal. Guarded — # degrade silently if Reline isn't the active input (e.g. in-turn). redisplay_idle_prompt end |
#select(prompt, choices) ⇒ Object
Arrow-key single-select menu — the SAME TTY::Prompt component the tool
approval menu uses (see #approval_choice), so /sessions resume reuses the
existing picker rather than introducing a second menu system (#145).
choices is an array of [label, value] pairs. Returns the chosen value,
or nil when there's no real terminal (so the caller keeps the
non-interactive shortcut). Esc/Ctrl-C cancels and returns nil — Esc via
the #cancellable_prompt keyescape binding (#73), Ctrl-C via tty-prompt's
own InputInterrupt; both land in the rescue below.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 |
# File 'lib/rubino/ui/cli.rb', line 359 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? # BUG 01 (Symptom B): drain in-flight keystrokes before the picker grabs # $stdin so a mid-turn type-ahead can't leak into its filter. A filtering # menu is a CHOICE, not a freeform answer — no queue line is consumed. BottomComposer.run_in_terminal_with_pending(consume_queue: false) do cancellable_prompt.select(prompt, cycle: false, filter: true) do || .help(FILTER_MENU_HELP) choices.each { |label, value| .choice label, value } end end rescue TTY::Reader::InputInterrupt # Esc aborts tty-prompt mid-render — the exception unwinds straight out of # its draw loop, so the per-frame refresh that would have CLEARED the just # drawn header + menu never runs. The frame is left committed to the # scrollback (a dead "Resume which session? …" / "Rewind to which # message? …" header + its first row), and repeated cancels stack corpses # (#219). Erase the picker's frame so cancel restores the prompt cleanly — # "nothing changed", as documented. The cursor is parked at the end of the # last menu row, so we walk up over every drawn line and wipe to the end # of the screen. erase_picker_frame(choices.length) nil end |
#separator ⇒ Object
607 608 609 |
# File 'lib/rubino/ui/cli.rb', line 607 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.
591 592 593 594 595 596 597 598 599 |
# File 'lib/rubino/ui/cli.rb', line 591 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".
573 574 575 576 577 578 579 580 581 582 583 584 585 |
# File 'lib/rubino/ui/cli.rb', line 573 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_cards ⇒ Object
Repaints the SUBAGENT CARD block in the live region from the BackgroundTasks registry (Variant A). Called whenever a background subagent's activity changes (a child tool started/finished, a spawn, a completion, an approval request) so the collapsed cards update IN PLACE without flooding scrollback. Renders the registry's CURRENT live snapshot rather than a single delta, so cards added/removed/updated all converge.
The card block only exists while a turn owns the bottom composer (BottomComposer.current); between turns there is no live region, so this is a quiet no-op (the /agents drill-in covers the idle case). Reads the registry under its own mutex via #running; the formatting is pure.
956 957 958 959 960 961 962 963 964 |
# File 'lib/rubino/ui/cli.rb', line 956 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.
1404 1405 1406 |
# File 'lib/rubino/ui/cli.rb', line 1404 def stash_probe_draft(text) @probe_draft = text end |
#status_back_to_thinking ⇒ Object
After a tool's └ ✓ close row commits, swap the status row back to the
thinking phase (the P4 inter-tool gap) with the accumulated stats. The
live row count is a simple per-turn UI tally — the footer's exact
ran/denied split from the Loop stays authoritative.
1813 1814 1815 1816 1817 1818 1819 1820 1821 |
# File 'lib/rubino/ui/cli.rb', line 1813 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) ---
1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 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 |
# File 'lib/rubino/ui/cli.rb', line 1180 def stream(chunk) type = chunk[:type] || :content text = chunk[:text].to_s return if text.empty? # A tool call is starting to stream (#608). The NAME arrives once on # :tool_preparing — open the tool card NOW, at the start of the call, so # the timeline shows the tool being invoked (NOT a "preparing" label in # the footer: the footer stays a pure token meter). The argument FRAGMENTS # then arrive on :tool_args — count them toward the live token meter (so a # long `write` shows the count climbing instead of freezing) and stream # them into the card as the live params. Neither enters the answer buffer. if type == :tool_preparing tool_params_begin(text) return end if type == :tool_args @turn_tok_chars += text.length if @turn_active tool_params_feed(text) return end @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).
1289 1290 1291 1292 1293 1294 1295 1296 1297 |
# File 'lib/rubino/ui/cli.rb', line 1289 def stream_block_end( = nil) return unless @stream_type stream_end return unless @turn_active && thinking_painter @thinking_indicator = true status_ensure("thinking", phase: :thinking) end |
#stream_end ⇒ Object
1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 |
# File 'lib/rubino/ui/cli.rb', line 1266 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_choice ⇒ Object
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/[n]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.
511 512 513 514 515 516 517 518 |
# File 'lib/rubino/ui/cli.rb', line 511 def subagent_approval_choice ("approve?", [ ["Approve once", :once], ["Approve always (this command)", :always_command], ["Deny", :no], ["Deny & tell the agent why", :deny_explain] ]) end |
#subagent_budget_choice ⇒ Object
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, :later→snooze (leave parked), :summarize→summarize.
"Decide later" sits BETWEEN grant and summarize on purpose (#586): the default highlight is the safe "Grant", and a stray ↓+Enter — the exact gesture used to open+attach in the subagent picker — lands on the non-destructive "Decide later", never on "Summarize now". The destructive option needs a deliberate ↓↓, so an auto-popped budget modal can't force-summarize a child by a mis-aimed picker keystroke.
532 533 534 535 536 537 538 |
# File 'lib/rubino/ui/cli.rb', line 532 def subagent_budget_choice ("grant more budget?", [ ["Grant more iterations", :grant], ["Decide later", :later], ["Summarize now", :summarize] ]) end |
#subagent_cards ⇒ Object
991 992 993 |
# File 'lib/rubino/ui/cli.rb', line 991 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.
854 855 856 857 858 859 860 861 |
# File 'lib/rubino/ui/cli.rb', line 854 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.
870 871 872 873 874 875 876 877 878 879 880 881 |
# File 'lib/rubino/ui/cli.rb', line 870 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.
746 747 748 |
# File 'lib/rubino/ui/cli.rb', line 746 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).
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/rubino/ui/cli.rb', line 165 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_draft ⇒ Object
Consumes the parked /probe draft (see #stash_probe_draft), or nil.
1409 1410 1411 1412 1413 |
# File 'lib/rubino/ui/cli.rb', line 1409 def take_probe_draft draft = @probe_draft @probe_draft = nil draft end |
#terminal_cols ⇒ Object
Terminal column count, headless-safe (falls back to 80).
263 264 265 266 267 268 269 270 |
# File 'lib/rubino/ui/cli.rb', line 263 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 ┄
1935 1936 1937 1938 1939 |
# File 'lib/rubino/ui/cli.rb', line 1935 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 ┄
1928 1929 1930 1931 |
# File 'lib/rubino/ui/cli.rb', line 1928 def think_status(effort) emit_blank emit("┄ effort: #{effort} ┄", style: :dim) end |
#thinking_elapsed_seconds ⇒ Object
Whole seconds the current/last thinking phase ran, for the collapse cue.
1594 1595 1596 1597 1598 |
# File 'lib/rubino/ui/cli.rb', line 1594 def thinking_elapsed_seconds return 0 unless @thinking_started_at (monotonic_now - @thinking_started_at).to_i end |
#thinking_finished ⇒ Object
Clears the status row for callers that bracket a synchronous wait with no stream lifecycle of their own — the /probe side-inference (#58). Public counterpart to #thinking_started; a no-op when nothing is showing. Outside a turn this also stops the engine thread.
1395 1396 1397 1398 |
# File 'lib/rubino/ui/cli.rb', line 1395 def thinking_finished clear_thinking_indicator status_stop unless @turn_active end |
#thinking_painter ⇒ Object
The per-frame paint strategy for the thinking animation, or nil when the output can't host one (a pipe with no composer). Frames go through #paint_live, which re-resolves the right seam on EVERY frame — so a ticker that outlives a composer/proxy swap can never paint through a stale handle (#169).
1424 1425 1426 1427 1428 |
# File 'lib/rubino/ui/cli.rb', line 1424 def thinking_painter return unless $stdout.respond_to?(:live) || BottomComposer.current || tty_stdout? method(:paint_live) end |
#thinking_started ⇒ Object
Shows the status row during the model wait. Mid-turn this only swaps the label back to "thinking" (the engine thread is already running); for a stand-alone wait with no turn bracket — the /probe side-inference (#58) — it starts the engine fresh. Frames go through #paint_live, so mid-turn they pass the composer's render mutex; on a BARE TTY with no #live seam the row repaints in place via CR + clear-line. Into a pipe it stays a single static dim print — never animate into a non-terminal.
1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 |
# File 'lib/rubino/ui/cli.rb', line 1373 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 |
#throttle_live? ⇒ Boolean
Coalesce live repaints only while the turn ticker thread is alive — it provides the trailing-edge flush (#flush_pending_live). Outside a turn (tests, the cooked /probe wait, plain non-TTY paths) there is no flusher, so paint every frame immediately to preserve exact legacy behavior.
1484 1485 1486 |
# File 'lib/rubino/ui/cli.rb', line 1484 def throttle_live? @thinking_thread&.alive? || false 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).
1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 |
# File 'lib/rubino/ui/cli.rb', line 1719 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, full: false) ⇒ 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.
1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 |
# File 'lib/rubino/ui/cli.rb', line 1744 def tool_chunk(_name, chunk, kind: :plain, full: false) 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 # +full+ (live `write`/`edit` params, #608d) shows EVERY line — the user # is watching the file being authored, not reviewing a finished dump. limit = tool_preview_limit if full || !limit.positive? write_body_lines(chunk.to_s) { |chomped| @pastel.dim(chomped) } @last_block = :tool 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>.
1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 |
# File 'lib/rubino/ui/cli.rb', line 1793 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_params_begin(name) ⇒ Object
The streaming tool call just started (its NAME arrived, #608). Open the
tool card at the START — before the arguments finish — so the user sees
the invocation and its params stream in, rather than a frozen footer.
#tool_started reconciles against @tool_params_open so the ● name header
is drawn exactly once. No-op off-turn or if this call is already open.
1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 |
# File 'lib/rubino/ui/cli.rb', line 1659 def tool_params_begin(name) return unless @turn_active return if @tool_params_open == name # `task` (subagent delegation) has a bespoke card (#delegation_started); # don't pre-open a generic `● task` that would clash with it. return if name == "task" # Close any open answer block first (the normal #tool_started path does # this via #finalize_stream) so the card never opens under a live tail. finalize_stream activity_started(name) @tool_params_open = name @tool_params_stream = ToolArgsStream.new # Keep the animated facet ALIVE for the whole argument stream (#608d). # A large `write` streams its content for MINUTES past the 30-line preview # cap; without a live status the screen sits silent (measured: ~38s of # dead UI on a 100-line file) and reads as a freeze. status_text already # renders the "writing" phase with the ~N-tok meter climbing — it just # needs the facet visible. Mirrors the SAME facet+tool_chunk pairing the # tool-EXECUTION phase (#tool_started) already uses, so the params scroll # above an animated `tool · Ns · ~N tok` footer instead of nothing. status_show(name, phase: :tool) if @turn_active end |
#tool_params_feed(fragment) ⇒ Object
An argument fragment of the in-flight call: decode the JSON arg VALUES and render the complete lines unlocked so far under the card (the partial last line is held by the decoder until its newline lands), reusing the head-N-lines preview collapse. Token counting happens in #stream.
1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 |
# File 'lib/rubino/ui/cli.rb', line 1687 def tool_params_feed(fragment) return unless @tool_params_stream lines = @tool_params_stream.feed(fragment) # Stream the params IN FULL (no 30-line preview cap): a `write`/`edit`'s # content is exactly what the user wants to watch land, line by line, as # the model generates it (#608d). The cap is for collapsing a finished # tool's OUTPUT in the transcript — not for hiding the file being authored # right now. Streaming every line also keeps the screen alive the whole # time, so a long write never sits silent. tool_chunk(@tool_params_open, lines, full: true) unless lines.empty? end |
#tool_params_flush ⇒ Object
Flush the held params tail and close the streaming-params state. Called when the tool actually starts (args complete) and at turn end so the last line is never lost.
1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 |
# File 'lib/rubino/ui/cli.rb', line 1703 def tool_params_flush stream = @tool_params_stream @tool_params_stream = nil @tool_params_open = nil return unless stream tail = stream.flush # Full (uncapped) to match the live params stream (#608d) — the final # partial line is part of the content the user is watching land. tool_chunk(@activity_name, "#{tail}\n", full: true) unless tail.empty? 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.
1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 |
# File 'lib/rubino/ui/cli.rb', line 1630 def tool_started(name, arguments: nil, at: nil, call_id: nil) record_subagent_tool_started(name, arguments) # Streaming path: the card was already opened at the START of the call # (#tool_params_begin) and its params streamed live. Commit the held # params tail and DON'T redraw the header — just relabel the status row. if @tool_params_open == name tool_params_flush return delegation_started(arguments, call_id) if name == "task" status_show(name, phase: :tool, hint: status_hint(arguments)) if @turn_active return end 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).
1567 1568 1569 1570 1571 |
# File 'lib/rubino/ui/cli.rb', line 1567 def tty_stdout? $stdout.respond_to?(:tty?) && $stdout.tty? rescue StandardError false end |
#turn_finished ⇒ Object
Marks the end of a TURN (normal completion, error, or interrupt): the one place the turn-scoped ticker thread is allowed to die.
1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 |
# File 'lib/rubino/ui/cli.rb', line 1348 def turn_finished elapsed = @turn_active && @turn_started_at ? monotonic_now - @turn_started_at : nil @turn_active = false @thinking_indicator = false status_stop # A completion stashed after the footer printed (or on an interrupted # turn that never got one) must not vanish — flush the full block. pending = Array(@pending_subagent_footers) @pending_subagent_footers = nil pending.each do |p| subagent_lifecycle(p[:line], status: p[:status] || "done", report: p[:report], id: p[:id]) end # Attention signal LAST, with the footer already committed: a LONG # turn rings the bell/hook so a human who looked away comes back; # quick turns stay silent (the notifier's min_turn_seconds gate). notifier.turn_finished(elapsed) if elapsed end |
#turn_footer(text) ⇒ Object
The STATIC turn footer rail, all dim: ┄ turn · 16.6s · 3 tools ┄.
No red ◆ — red is the error color; the animated status row keeps its
red facet as the living brand mark (P4). Attached directly under the
answer with no leading blank (P3). Subagent completions stashed
mid-turn (#subagent_finished) fold into the grammar instead of
stacking a second ┄ ┄ rail right at turn end:
┄ turn · 16.6s · 3 tools · 105 tok · sa_e488 done ┄
841 842 843 844 845 846 847 |
# File 'lib/rubino/ui/cli.rb', line 841 def (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_interrupted ⇒ Object
Commits the standardized interrupt marker right after the partial answer
that was kept when a turn is cancelled (Ctrl+C, or the interrupt-by-
default Enter): a dim ⎿ interrupted row, house grammar. Leading CR +
clear-line so it lands cleanly even if the cursor is sitting after a
partial stream chunk. This is the single visible interrupt notice — the
runner no longer also prints a separate "interrupted by user" warning.
Tears down a still-ticking "thinking…" animation first, same as the
error path (#74) — Loop#stream_end usually already did, but an
interrupt raised outside the streaming bracket must settle too.
Swallowed once after a QUIET slash-command interrupt (#111, above).
760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 |
# File 'lib/rubino/ui/cli.rb', line 760 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_started ⇒ Object
Marks the start of a TURN: resets the per-turn stats and starts the status-row engine in its initial "thinking" phase (the P1 wait). Called by the chat loop right before the runner takes over; guarded with respond_to? at the call site so other UI adapters are unaffected.
1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 |
# File 'lib/rubino/ui/cli.rb', line 1317 def turn_started @turn_active = true @turn_started_at = monotonic_now @turn_tool_count = 0 @turn_tok_chars = 0 # Streaming-params card state (#608): no tool call is mid-stream yet. @tool_params_open = nil @tool_params_stream = nil # 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 |