1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
|
-- Live progress cards for in-flight children.
--
-- While the primary model is blocked on one of the subagents.* tools, its
-- children are streaming. This folds each child's event stream into one small
-- card and renders the set as the component of the tool-call entry that
-- started them, so several concurrent children stay visually distinct and the
-- cards disappear with the entry they belong to. The events are presentation
-- data only: nothing here ever reaches a conversation.
--
-- Linking a card to the right entry. The host fires `tool_call_complete` with
-- the tool-use id just before dispatching that call, and hands the same id to
-- the handler as `context.tool_call_id`; `claim` records the board under the
-- id, `bind` ties the running handler coroutine to it, and `card` finds the
-- board of whichever coroutine is asking. A workflow callback runs on its
-- handler's own coroutine, so a card raised deep inside one still lands on the
-- entry the model can see.
--
-- Everything degrades to a no-op card: in print mode, in a test, or under a
-- host with no component machinery there is no board to attach to, and a child
-- still runs exactly the same.
--
-- Rendering is deliberately small — a four-line ring per card, truncated to the
-- terminal width — because several children run at once and the panel must not
-- push the transcript off the screen.
local MAX_CARD_LINES = 4
local MAX_LINE_BYTES = 1024
local BODY_INDENT = " "
local TOOL_PREFIX = "subagents."
local GLYPHS = {
running = "◷",
completed = "✔",
failed = "✖",
cancelled = "⊘",
}
local M = {}
-- Boards keyed by tool-call id, and the board bound to each handler coroutine.
-- Weak keys on the second so a finished handler's binding disappears with it.
local boards = {}
local bound = setmetatable({}, { __mode = "k" })
-- ---------------------------------------------------------------------------
-- Text handling
-- ---------------------------------------------------------------------------
-- Control bytes would move the cursor or confuse the renderer's width
-- accounting, so they become spaces. UTF-8 continuation bytes are >= 0x80 and
-- pass through untouched.
local function sanitize(text)
return (text:gsub("[%z\1-\31\127]", " "))
end
local function truncate(text, width)
if width == nil or width < 1 then
return text
end
local length = utf8.len(text)
if length == nil or length <= width then
return text
end
return text:sub(1, (utf8.offset(text, width + 1) or (#text + 1)) - 1)
end
-- ---------------------------------------------------------------------------
-- Cards
-- ---------------------------------------------------------------------------
local card_mt = {}
card_mt.__index = card_mt
local function repaint(card)
local board = card.board
if board and board.handle then
pcall(board.handle.invalidate, board.handle)
end
end
local function adopt(card, line)
card.lines[#card.lines + 1] = line
while #card.lines > MAX_CARD_LINES do
table.remove(card.lines, 1)
end
end
-- A discrete, already-complete line: a tool marker, a model label, an error.
local function marker(card, prefix, text)
card.partial = false
if text == nil or text == "" then
return
end
adopt(card, prefix .. sanitize(text:sub(1, MAX_LINE_BYTES)))
end
-- Fold a text delta in, breaking it on newlines. A chunk with no newline leaves
-- the tail line open so the next delta continues it.
local function append_text(card, text)
local rest = text
while rest ~= "" do
local newline = rest:find("\n", 1, true)
local chunk = newline and rest:sub(1, newline - 1) or rest
if card.partial and #card.lines > 0 then
local index = #card.lines
local grown = card.lines[index] .. sanitize(chunk)
card.lines[index] = grown:sub(1, MAX_LINE_BYTES)
elseif chunk ~= "" then
adopt(card, sanitize(chunk:sub(1, MAX_LINE_BYTES)))
end
card.partial = newline == nil
rest = newline and rest:sub(newline + 1) or ""
end
end
-- One run_async event. `content_delta` carries only an index, so the block type
-- comes from the `block_start` that opened it — the same flag the v0 pump kept.
function card_mt:event(event)
if type(event) ~= "table" then
return
end
local kind = event.type
if kind == "block_start" then
self.text_block = event.block_type == "text"
elseif kind == "content_delta" then
if self.text_block and type(event.delta) == "string" then
append_text(self, event.delta)
end
elseif kind == "tool_details" then
marker(self, "⚒ ", event.name)
elseif kind == "tool_dispatch_complete" or kind == "message_complete" then
self.partial = false
else
return
end
repaint(self)
end
-- The child settled. Only a failure or a cancellation gets a closing line; a
-- completed child's report is the tool result the model already sees.
function card_mt:done(status, message)
self.status = GLYPHS[status] and status or "failed"
if self.status ~= "completed" then
marker(self, "", message)
else
self.partial = false
end
repaint(self)
end
local NOOP = setmetatable({ lines = {} }, {
__index = {
event = function() end,
done = function() end,
},
})
-- ---------------------------------------------------------------------------
-- Boards (one per tool-call entry)
-- ---------------------------------------------------------------------------
local function render(board, width)
local out = {}
if #board.cards == 0 or (width or 0) <= 4 then
return out
end
out[#out + 1] = ""
for _, card in ipairs(board.cards) do
local sid = card.sid ~= "" and (" " .. card.sid) or ""
out[#out + 1] = truncate(string.format("%s %s%s — %s",
GLYPHS[card.status] or GLYPHS.running, card.label, sid, card.status), width)
for _, line in ipairs(card.lines) do
out[#out + 1] = BODY_INDENT .. truncate(line, width - #BODY_INDENT)
end
end
return out
end
local function new_board()
local board = { cards = {}, handle = nil }
board.component = {
render = function(_, width)
return render(board, width)
end,
}
return board
end
-- ---------------------------------------------------------------------------
-- Host wiring
-- ---------------------------------------------------------------------------
-- `tool_call_complete` for one of our tools: claim that entry's component so
-- the cards the handler is about to raise have somewhere to render.
function M.claim(event)
-- The event is host userdata; an unknown field answers nil rather than
-- raising, and activation already refused a host too old to have these.
local name = event.tool_name
if type(name) ~= "string" or name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then
return
end
if type(event.set_component) ~= "function" then
return
end
local key = event.id or name
local board = new_board()
local attached, handle = pcall(event.set_component, event, board.component)
if not attached then
return
end
board.handle = handle
boards[key] = board
end
-- Tie the running handler coroutine to the entry it was dispatched for.
function M.bind(context)
local key = type(context) == "table" and context.tool_call_id or nil
if key == nil then
return
end
bound[coroutine.running()] = key
end
-- card(label, id, detail) -> card
--
-- The card of the entry whose handler is running, or an inert one when there is
-- no such entry (print mode, a plain script, a host without components).
function M.card(label, id, detail)
local key = bound[coroutine.running()]
local board = key and boards[key]
if board == nil or (board.handle and board.handle.alive and not board.handle:alive()) then
return NOOP
end
local card = setmetatable({
board = board,
label = tostring(label or "subagent"),
sid = type(id) == "string" and id:sub(1, 8) or "",
status = "running",
lines = {},
partial = false,
text_block = false,
}, card_mt)
board.cards[#board.cards + 1] = card
marker(card, "↳ ", detail)
repaint(card)
return card
end
-- End of turn: the entries are gone, and the primary's own tool results are the
-- durable record from here on.
function M.reset()
boards = {}
bound = setmetatable({}, { __mode = "k" })
end
return M
|