Class: Rubino::UI::SubagentView
- Defined in:
- lib/rubino/ui/subagent_view.rb
Overview
Nested UI adapter for a running subagent (the ‘task` tool).
While the parent loop delegates to a subagent, the child runs its own isolated Agent::Runner. By default that child is wired with UI::Null, so its activity is invisible. This adapter makes the child’s TOOL ACTIVITY visible INLINE — compact rows indented under the parent’s “● delegated → X” delegation boundary, in a per-subagent color — so the user can watch what the subagent is doing live.
DISPLAY ONLY. This adapter writes to $stdout (which, during a parent turn, is the composer proxy → committed above the bottom composer like every other timeline row). It never touches the parent loop’s ‘messages` or the parent recorder: the result-only contract is unchanged. The parent model still receives ONLY the subagent’s final result (the ‘task` tool result).
COLLAPSED-CARD MODE (Variant A — kills the flood, #124): instead of writing one $stdout row per child tool call (which buried the parent prompt), tool_started/tool_finished now feed the BackgroundTasks REGISTRY entry for this run (last_activity + a tool counter + a bounded recent-ring) and ask the parent UI to repaint its collapsed live CARD. The card shows a single in-place line per subagent (‘▸ sa_… · explore · running · N tools ·Ns · <last_activity>`) that updates without scrolling — see UI::CLI #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the same registry ring for the live recent: list (#71) and the entry’s output_tail — fed by #tool_chunk — for the live output: block (#5).
The view is wired with the entry id at construction (TaskTool builds it per background run). With no id (legacy/foreground synchronous path, tests) it falls back to the OLD inline rows so the synchronous delegation surface and its specs are unchanged.
Inline (legacy) format, 2-space extra indent under the delegation row:
` ⟂ explore · read lib/foo.rb`
` ⟂ explore · ✓ grep · 3 matches`
Noise control:
- stream / stream_end / assistant_text / thinking_started are SUPPRESSED
(the subagent's prose isn't shown — only its steps and the final
result, which the parent already prints as "✓ X: result");
- note / status / info render as dim nested lines (low-noise) ONLY in the
legacy inline path; in card mode they fold into the registry too;
- confirm: in card mode it does NOT auto-deny — it surfaces the approval
on the card and parks the child on a per-entry gate (Option 2; wired by
TaskTool). With no approval handler (legacy/foreground) it auto-DENIES
so a subagent never blocks on a prompt no one can answer.
Constant Summary collapse
- PALETTE =
Deterministic per-subagent palette. Chosen by hashing the agent name so the same subagent always renders in the same color (no Math.random), and concurrent/sequential delegations to different subagents stay visually distinct. All names are valid Pastel foreground colors.
%i[cyan magenta blue yellow green bright_cyan].freeze
- INDENT =
Nested-row indent: 2 spaces beyond the CLI’s own 2-space body indent so the subagent’s steps read as nested under the “● delegated → X” row.
" "- GLYPH =
Glyph prefixing every subagent activity row.
"⟂"
Instance Attribute Summary collapse
-
#color ⇒ Object
readonly
The color this view paints its rows in (exposed for tests).
Instance Method Summary collapse
-
#ask(_prompt) ⇒ Object
No interactive clarification mid-delegation either.
- #assistant_text(_text) ⇒ Object
- #blank_line ⇒ Object
- #body(_text) ⇒ Object
- #box_close(*_pieces, color: nil) ⇒ Object
- #box_open(*_pieces, at: nil, color: nil) ⇒ Object
-
#card_mode? ⇒ Boolean
True when this view feeds a registry entry (collapsed-card mode) rather than flooding $stdout with per-tool rows (legacy inline mode).
- #compression_finished(_metadata, at: nil) ⇒ Object
- #compression_started(at: nil) ⇒ Object
-
#confirm(question, scope: nil, **context) ⇒ Object
Option 2 — approval-surfacing.
- #error(message) ⇒ Object
- #info(text) ⇒ Object
-
#initialize(agent_name:, out: $stdout, pastel: Pastel.new, entry_id: nil, parent_ui: nil, approve: nil) ⇒ SubagentView
constructor
A new instance of SubagentView.
- #input_injected(_text) ⇒ Object
- #job_enqueued(_type) ⇒ Object
- #job_finished(_type) ⇒ Object
- #job_started(_type) ⇒ Object
- #mode_changed(_name, previous: nil) ⇒ Object
-
#note(text) ⇒ Object
In card mode these fold away (the card is the only surface); in legacy inline mode they keep their dim nested rows.
- #queued(_text) ⇒ Object
- #replay_user_input(_text, at: nil) ⇒ Object
-
#separator ⇒ Object
— Suppressed lifecycle chrome ————————————.
- #status(text) ⇒ Object
-
#stream(_chunk) ⇒ Object
— Suppressed: the child’s prose / token stream ———————.
- #stream_end ⇒ Object
- #success(message) ⇒ Object
- #table(headers:, rows:) ⇒ Object
- #thinking_started ⇒ Object
-
#tool_body(_text, kind: :plain) ⇒ Object
tool_body: the END-OF-CALL preview of a NON-streaming tool (the executor skips it when the tool streamed via #tool_chunk).
-
#tool_chunk(_name, chunk) ⇒ Object
Card mode: append the streamed chunk to the entry’s bounded output tail — the live output: block the /agents <id> watch tails while THIS tool runs (#5).
-
#tool_finished(name, result: nil) ⇒ Object
Card mode: append the terse finish line to the entry’s recent-ring (which the /agents drill-in tails) and repaint.
-
#tool_started(name, arguments: nil, at: nil) ⇒ Object
Card mode: record the tool start on the registry entry (last_activity + tool counter) and repaint the parent’s collapsed card — NO $stdout row, so a read-heavy child never floods the parent terminal (#124).
- #warning(message) ⇒ Object
Methods inherited from Base
#blocking_human_input?, #confirm_destructive, #hint_row, #panel_line, #select, #turn_interrupted
Constructor Details
#initialize(agent_name:, out: $stdout, pastel: Pastel.new, entry_id: nil, parent_ui: nil, approve: nil) ⇒ SubagentView
Returns a new instance of SubagentView.
75 76 77 78 79 80 81 82 83 84 |
# File 'lib/rubino/ui/subagent_view.rb', line 75 def initialize(agent_name:, out: $stdout, pastel: Pastel.new, entry_id: nil, parent_ui: nil, approve: nil) @agent_name = agent_name.to_s @out = out @pastel = pastel @color = PALETTE[color_index(@agent_name)] @entry_id = entry_id @parent_ui = parent_ui @approve = approve end |
Instance Attribute Details
#color ⇒ Object (readonly)
The color this view paints its rows in (exposed for tests).
87 88 89 |
# File 'lib/rubino/ui/subagent_view.rb', line 87 def color @color end |
Instance Method Details
#ask(_prompt) ⇒ Object
No interactive clarification mid-delegation either.
200 201 202 |
# File 'lib/rubino/ui/subagent_view.rb', line 200 def ask(_prompt) nil end |
#assistant_text(_text) ⇒ Object
147 |
# File 'lib/rubino/ui/subagent_view.rb', line 147 def assistant_text(_text); end |
#blank_line ⇒ Object
168 |
# File 'lib/rubino/ui/subagent_view.rb', line 168 def blank_line; end |
#body(_text) ⇒ Object
148 |
# File 'lib/rubino/ui/subagent_view.rb', line 148 def body(_text); end |
#box_close(*_pieces, color: nil) ⇒ Object
176 |
# File 'lib/rubino/ui/subagent_view.rb', line 176 def box_close(*_pieces, color: nil); end |
#box_open(*_pieces, at: nil, color: nil) ⇒ Object
175 |
# File 'lib/rubino/ui/subagent_view.rb', line 175 def box_open(*_pieces, at: nil, color: nil); end |
#card_mode? ⇒ Boolean
True when this view feeds a registry entry (collapsed-card mode) rather than flooding $stdout with per-tool rows (legacy inline mode).
91 92 93 |
# File 'lib/rubino/ui/subagent_view.rb', line 91 def card_mode? !@entry_id.nil? end |
#compression_finished(_metadata, at: nil) ⇒ Object
170 |
# File 'lib/rubino/ui/subagent_view.rb', line 170 def compression_finished(, at: nil); end |
#compression_started(at: nil) ⇒ Object
169 |
# File 'lib/rubino/ui/subagent_view.rb', line 169 def compression_started(at: nil); end |
#confirm(question, scope: nil, **context) ⇒ Object
Option 2 — approval-surfacing. In card mode WITH an approval handler (wired by TaskTool), a child tool that needs approval is NOT silently denied: we hand off to @approve, which flips the registry entry to :needs_approval (surfacing it on the card + a parent note) and BLOCKS the child thread on a per-entry Run::ApprovalGate until the user answers via /agents <id> (or the 15-min bound auto-denies). The handler returns the boolean decision, which we return so the child’s tool proceeds or denies.
Without a handler (legacy inline / foreground path) we keep the old AUTO-DENY (false): a subagent there must never hang on a prompt no one can answer.
193 194 195 196 197 |
# File 'lib/rubino/ui/subagent_view.rb', line 193 def confirm(question, scope: nil, **context) return @approve.call(question, scope: scope, **context) if @approve false end |
#error(message) ⇒ Object
163 |
# File 'lib/rubino/ui/subagent_view.rb', line 163 def error() = card_mode? ? nil : row("✗ #{}") |
#info(text) ⇒ Object
159 |
# File 'lib/rubino/ui/subagent_view.rb', line 159 def info(text) = card_mode? ? nil : dim_row(text) |
#input_injected(_text) ⇒ Object
178 |
# File 'lib/rubino/ui/subagent_view.rb', line 178 def input_injected(_text); end |
#job_enqueued(_type) ⇒ Object
171 |
# File 'lib/rubino/ui/subagent_view.rb', line 171 def job_enqueued(_type); end |
#job_finished(_type) ⇒ Object
173 |
# File 'lib/rubino/ui/subagent_view.rb', line 173 def job_finished(_type); end |
#job_started(_type) ⇒ Object
172 |
# File 'lib/rubino/ui/subagent_view.rb', line 172 def job_started(_type); end |
#mode_changed(_name, previous: nil) ⇒ Object
174 |
# File 'lib/rubino/ui/subagent_view.rb', line 174 def mode_changed(_name, previous: nil); end |
#note(text) ⇒ Object
In card mode these fold away (the card is the only surface); in legacy inline mode they keep their dim nested rows.
157 |
# File 'lib/rubino/ui/subagent_view.rb', line 157 def note(text) = card_mode? ? nil : dim_row(text) |
#queued(_text) ⇒ Object
177 |
# File 'lib/rubino/ui/subagent_view.rb', line 177 def queued(_text); end |
#replay_user_input(_text, at: nil) ⇒ Object
150 |
# File 'lib/rubino/ui/subagent_view.rb', line 150 def replay_user_input(_text, at: nil); end |
#separator ⇒ Object
— Suppressed lifecycle chrome ————————————
167 |
# File 'lib/rubino/ui/subagent_view.rb', line 167 def separator; end |
#status(text) ⇒ Object
158 |
# File 'lib/rubino/ui/subagent_view.rb', line 158 def status(text) = card_mode? ? nil : dim_row(text) |
#stream(_chunk) ⇒ Object
— Suppressed: the child’s prose / token stream ———————
145 |
# File 'lib/rubino/ui/subagent_view.rb', line 145 def stream(_chunk); end |
#stream_end ⇒ Object
146 |
# File 'lib/rubino/ui/subagent_view.rb', line 146 def stream_end; end |
#success(message) ⇒ Object
161 |
# File 'lib/rubino/ui/subagent_view.rb', line 161 def success() = card_mode? ? nil : row("✓ #{}") |
#table(headers:, rows:) ⇒ Object
151 |
# File 'lib/rubino/ui/subagent_view.rb', line 151 def table(headers:, rows:); end |
#thinking_started ⇒ Object
149 |
# File 'lib/rubino/ui/subagent_view.rb', line 149 def thinking_started; end |
#tool_body(_text, kind: :plain) ⇒ Object
tool_body: the END-OF-CALL preview of a NON-streaming tool (the executor skips it when the tool streamed via #tool_chunk). Useless for the live tail — tool_finished wipes the buffer right after — so it stays quiet.
141 |
# File 'lib/rubino/ui/subagent_view.rb', line 141 def tool_body(_text, kind: :plain); end |
#tool_chunk(_name, chunk) ⇒ Object
Card mode: append the streamed chunk to the entry’s bounded output tail —the live output: block the /agents <id> watch tails while THIS tool runs (#5). Registry-only, NO card repaint: a chatty shell streams a chunk per line and repainting the cards per chunk would flood the parent terminal (the watch drill-in re-reads the tail on its own tick). Legacy mode stays quiet (the start/finish rows already say what ran).
134 135 136 |
# File 'lib/rubino/ui/subagent_view.rb', line 134 def tool_chunk(_name, chunk) Tools::BackgroundTasks.instance.record_tool_output(@entry_id, chunk) if card_mode? end |
#tool_finished(name, result: nil) ⇒ Object
Card mode: append the terse finish line to the entry’s recent-ring (which the /agents drill-in tails) and repaint. Legacy mode: the old inline row.
115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/rubino/ui/subagent_view.rb', line 115 def tool_finished(name, result: nil) failed = result.respond_to?(:success?) && !result.success? icon = failed ? "✗" : "✓" suffix = result_metric(result) body = suffix ? "#{icon} #{name} · #{suffix}" : "#{icon} #{name}" if card_mode? Tools::BackgroundTasks.instance.record_tool_finished(@entry_id, body) repaint_cards else row(body) end end |
#tool_started(name, arguments: nil, at: nil) ⇒ Object
Card mode: record the tool start on the registry entry (last_activity + tool counter) and repaint the parent’s collapsed card — NO $stdout row, so a read-heavy child never floods the parent terminal (#124). Legacy mode: the old inline ‘ ⟂ explore · read lib/foo.rb` row.
101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/rubino/ui/subagent_view.rb', line 101 def tool_started(name, arguments: nil, at: nil) hint = args_hint(arguments) if card_mode? activity = hint ? "#{name} #{hint}" : name.to_s Tools::BackgroundTasks.instance.record_tool_started(@entry_id, activity) repaint_cards else body = hint ? "#{name} #{hint}" : name.to_s row(body) end end |
#warning(message) ⇒ Object
162 |
# File 'lib/rubino/ui/subagent_view.rb', line 162 def warning() = card_mode? ? nil : row("⚠ #{}") |