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).
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 / tool_chunk: the child’s tool previews/streamed chunks.
- #tool_chunk(_name, _chunk) ⇒ Object
-
#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.
74 75 76 77 78 79 80 81 82 83 |
# File 'lib/rubino/ui/subagent_view.rb', line 74 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).
86 87 88 |
# File 'lib/rubino/ui/subagent_view.rb', line 86 def color @color end |
Instance Method Details
#ask(_prompt) ⇒ Object
No interactive clarification mid-delegation either.
189 190 191 |
# File 'lib/rubino/ui/subagent_view.rb', line 189 def ask(_prompt) nil end |
#assistant_text(_text) ⇒ Object
136 |
# File 'lib/rubino/ui/subagent_view.rb', line 136 def assistant_text(_text); end |
#blank_line ⇒ Object
157 |
# File 'lib/rubino/ui/subagent_view.rb', line 157 def blank_line; end |
#body(_text) ⇒ Object
137 |
# File 'lib/rubino/ui/subagent_view.rb', line 137 def body(_text); end |
#box_close(*_pieces, color: nil) ⇒ Object
165 |
# File 'lib/rubino/ui/subagent_view.rb', line 165 def box_close(*_pieces, color: nil); end |
#box_open(*_pieces, at: nil, color: nil) ⇒ Object
164 |
# File 'lib/rubino/ui/subagent_view.rb', line 164 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).
90 91 92 |
# File 'lib/rubino/ui/subagent_view.rb', line 90 def card_mode? !@entry_id.nil? end |
#compression_finished(_metadata, at: nil) ⇒ Object
159 |
# File 'lib/rubino/ui/subagent_view.rb', line 159 def compression_finished(, at: nil); end |
#compression_started(at: nil) ⇒ Object
158 |
# File 'lib/rubino/ui/subagent_view.rb', line 158 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.
182 183 184 185 186 |
# File 'lib/rubino/ui/subagent_view.rb', line 182 def confirm(question, scope: nil, **context) return @approve.call(question, scope: scope, **context) if @approve false end |
#error(message) ⇒ Object
152 |
# File 'lib/rubino/ui/subagent_view.rb', line 152 def error() = card_mode? ? nil : row("✗ #{}") |
#info(text) ⇒ Object
148 |
# File 'lib/rubino/ui/subagent_view.rb', line 148 def info(text) = card_mode? ? nil : dim_row(text) |
#input_injected(_text) ⇒ Object
167 |
# File 'lib/rubino/ui/subagent_view.rb', line 167 def input_injected(_text); end |
#job_enqueued(_type) ⇒ Object
160 |
# File 'lib/rubino/ui/subagent_view.rb', line 160 def job_enqueued(_type); end |
#job_finished(_type) ⇒ Object
162 |
# File 'lib/rubino/ui/subagent_view.rb', line 162 def job_finished(_type); end |
#job_started(_type) ⇒ Object
161 |
# File 'lib/rubino/ui/subagent_view.rb', line 161 def job_started(_type); end |
#mode_changed(_name, previous: nil) ⇒ Object
163 |
# File 'lib/rubino/ui/subagent_view.rb', line 163 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.
146 |
# File 'lib/rubino/ui/subagent_view.rb', line 146 def note(text) = card_mode? ? nil : dim_row(text) |
#queued(_text) ⇒ Object
166 |
# File 'lib/rubino/ui/subagent_view.rb', line 166 def queued(_text); end |
#replay_user_input(_text, at: nil) ⇒ Object
139 |
# File 'lib/rubino/ui/subagent_view.rb', line 139 def replay_user_input(_text, at: nil); end |
#separator ⇒ Object
— Suppressed lifecycle chrome ————————————
156 |
# File 'lib/rubino/ui/subagent_view.rb', line 156 def separator; end |
#status(text) ⇒ Object
147 |
# File 'lib/rubino/ui/subagent_view.rb', line 147 def status(text) = card_mode? ? nil : dim_row(text) |
#stream(_chunk) ⇒ Object
— Suppressed: the child’s prose / token stream ———————
134 |
# File 'lib/rubino/ui/subagent_view.rb', line 134 def stream(_chunk); end |
#stream_end ⇒ Object
135 |
# File 'lib/rubino/ui/subagent_view.rb', line 135 def stream_end; end |
#success(message) ⇒ Object
150 |
# File 'lib/rubino/ui/subagent_view.rb', line 150 def success() = card_mode? ? nil : row("✓ #{}") |
#table(headers:, rows:) ⇒ Object
140 |
# File 'lib/rubino/ui/subagent_view.rb', line 140 def table(headers:, rows:); end |
#thinking_started ⇒ Object
138 |
# File 'lib/rubino/ui/subagent_view.rb', line 138 def thinking_started; end |
#tool_body(_text, kind: :plain) ⇒ Object
tool_body / tool_chunk: the child’s tool previews/streamed chunks. Kept quiet to stay low-noise — the start/finish rows already say what ran.
129 |
# File 'lib/rubino/ui/subagent_view.rb', line 129 def tool_body(_text, kind: :plain); end |
#tool_chunk(_name, _chunk) ⇒ Object
130 |
# File 'lib/rubino/ui/subagent_view.rb', line 130 def tool_chunk(_name, _chunk); 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.
114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/rubino/ui/subagent_view.rb', line 114 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.
100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/rubino/ui/subagent_view.rb', line 100 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
151 |
# File 'lib/rubino/ui/subagent_view.rb', line 151 def warning() = card_mode? ? nil : row("⚠ #{}") |