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