Class: Rubino::UI::SubagentCards

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/ui/subagent_cards.rb

Overview

Formats BackgroundTasks registry entries into the COLLAPSED LIVE CARDS the parent shows while one or more background subagents run (Variant A of the orchestration-UX blueprint). This is the single source of card text: the live region (UI::CLI#set_subagent_cards → BottomComposer) renders it while a turn runs, and the /agents drill-in reuses the same formatter for the expanded view. Pure formatting — it never touches the registry mutex itself (callers pass a snapshot) and writes nothing; the renderer decides where the lines go.

Collapsed card (one row per running subagent, updates in place):

▸ sa_9ae4 · explore · running · 14 tools · 38s · grep "def authenticate"

plus a single shared hint line under the block.

An entry parked on a human approval shows the approval prominently instead:

● sa_9ae4 · explore · needs approval · shell rm -rf build

Up to MAX_CARDS cards stack; a longer list collapses the overflow into a “+N more” tail so the live region stays bounded (and the single-row clamp in the composer never has to host an unbounded block).

Constant Summary collapse

MAX_CARDS =

Cap the live block so it never grows past the registry’s own MAX_CONCURRENT (3) live children — but defend against a stale/over-long list anyway with an explicit overflow tail.

Tools::BackgroundTasks::MAX_CONCURRENT
DEFAULT_CARD_WIDTH =

The display-column budget every card row is bounded to when the caller does not pass the live pane width. Several concurrent ‘needs approval` cards previously rendered at WHATEVER length their (model-chosen) command made them — so two parked children sat at different right edges and the longer one wrapped mid-word onto a second physical line at a stray column. Bounding EVERY row to one budget (and eliding on a glyph boundary, never mid-word) keeps the concurrent toasts a calm, left-aligned, single-line stack. A real pane width (when threaded through) overrides this default.

100
COLLAPSED =

Collapsed glyph (a running card) / approval glyph (needs the human) / BLOCKED glyph (an escalated ask_parent waiting on the human — RESERVED for “the tree is blocked on you” and nothing else, the distinct-signal rule).

""
APPROVAL =
""
BLOCKED =
""

Instance Method Summary collapse

Constructor Details

#initialize(pastel: Pastel.new) ⇒ SubagentCards

Returns a new instance of SubagentCards.



49
50
51
# File 'lib/rubino/ui/subagent_cards.rb', line 49

def initialize(pastel: Pastel.new)
  @pastel = pastel
end

Instance Method Details

#approval_card_line(entry) ⇒ Object

A card for a child parked on a human approval — the approval is the most important thing on the row, so it leads (amber ●) with the command. A BUDGET request (#574) reuses the same parked state but reads as a budget grant, not a tool approval, so the human knows what they’re granting.



109
110
111
112
113
114
115
116
117
118
# File 'lib/rubino/ui/subagent_cards.rb', line 109

def approval_card_line(entry)
  return budget_card_line(entry) if entry.budget_request

  glyph   = @pastel.yellow(APPROVAL)
  command = entry.approval_command.to_s
  command = entry.approval_question.to_s if command.empty?
  "  #{glyph} #{entry.id} · #{safe(card_label(entry))} · " +
    @pastel.yellow("needs approval") + ": #{safe(first_line(command, 60))} " \
                                       "· ↓ to approve"
end

#blocked_card_line(entry) ⇒ Object

A card for a child parked on an escalated ask_parent — the ⛔ “tree is blocked on YOU” row, the loudest state. Leads with the red ⛔ glyph and the question. The reply prompt AUTO-OPENS (#510/#513); the card just signals the state and points at the same arrow navigation (↓).



97
98
99
100
101
102
103
# File 'lib/rubino/ui/subagent_cards.rb', line 97

def blocked_card_line(entry)
  glyph    = @pastel.red(BLOCKED)
  question = entry.ask_question.to_s
  "  #{glyph} #{entry.id} · #{safe(card_label(entry))} · " +
    @pastel.red("waiting on you") + ": #{safe(first_line(question, 60))} " \
                                    "· ↓ to answer"
end

#budget_card_line(entry) ⇒ Object

A card for a child parked asking for MORE budget (#574): it hit its tool-iteration ceiling and wants the human to grant more iterations.



122
123
124
125
126
127
128
# File 'lib/rubino/ui/subagent_cards.rb', line 122

def budget_card_line(entry)
  glyph    = @pastel.yellow(APPROVAL)
  question = entry.approval_question.to_s
  "  #{glyph} #{entry.id} · #{safe(card_label(entry))} · " +
    @pastel.yellow("wants +budget") + ": #{safe(first_line(question, 60))} " \
                                      "· ↓ to grant"
end

#card_line(entry) ⇒ Object

One collapsed card row for a single entry.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/rubino/ui/subagent_cards.rb', line 74

def card_line(entry)
  if entry.status == :blocked_on_human
    blocked_card_line(entry)
  elsif entry.status == :needs_approval
    approval_card_line(entry)
  else
    glyph = @pastel.cyan(COLLAPSED)
    state = entry.status == :stopping ? "stopping" : "running"
    count = entry.tool_count.to_i
    # Compact card: id · label · state · N tools · elapsed. The per-tool
    # last_activity (often a long grep/glob arg or absolute path) is NOT
    # shown here — too noisy on the always-visible card; the live detail
    # lives in the agent's own view (Enter) / drill-in.
    body = "#{entry.id} · #{safe(card_label(entry))} · #{state} · " \
           "#{count} tool#{"s" if count != 1} · #{elapsed(entry)}"
    "  #{glyph} #{body}"
  end
end

#card_lines(entries, width: DEFAULT_CARD_WIDTH) ⇒ Object

Renders the live CARD BLOCK for the running (or approval-pending) children in entries as an array of ready-to-print lines. Returns [] when nothing is live, so the renderer can clear the region. entries is a snapshot (BackgroundTasks#running) taken under the registry mutex by the caller — this method only reads the plain struct fields.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/rubino/ui/subagent_cards.rb', line 58

def card_lines(entries, width: DEFAULT_CARD_WIDTH)
  live = entries.select { |e| live?(e) }
  return [] if live.empty?

  shown    = live.first(MAX_CARDS)
  overflow = live.size - shown.size
  lines    = shown.map { |e| clamp_row(card_line(e), width) }
  lines << @pastel.dim("  + #{overflow} more · /agents") if overflow.positive?
  # Count blocked children over the FULL live list (pre-cap), not just the
  # shown cards, so the aggregated ⛔N is the true number waiting on the
  # human even when some are hidden behind the MAX_CARDS overflow (#475-4).
  lines << hint_line(live)
  lines
end