Class: Rubino::UI::SubagentView

Inherits:
Base
  • Object
show all
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

Instance Method Summary collapse

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.

Parameters:

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

    the BackgroundTasks entry this view feeds in card mode. nil ⇒ legacy inline-row mode (synchronous/foreground path).

  • parent_ui (UI::CLI, nil) (defaults to: nil)

    the parent CLI whose live region hosts the collapsed card; #set_subagent_cards repaints it. Captured at spawn on the parent thread (the child thread has no access to the parent’s UI).

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

    in card mode, the approval handler TaskTool wires: called with (question, scope:, command:) and returns the boolean decision. nil ⇒ #confirm auto-denies (legacy behavior).



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

#colorObject (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_lineObject



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

Returns:

  • (Boolean)


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(message)   = card_mode? ? nil : row("#{message}")

#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

#separatorObject

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



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(message) = card_mode? ? nil : row("#{message}")

#table(headers:, rows:) ⇒ Object



140
# File 'lib/rubino/ui/subagent_view.rb', line 140

def table(headers:, rows:); end

#thinking_startedObject



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(message) = card_mode? ? nil : row("#{message}")