diff options
Diffstat (limited to 'subagents/progress.lua')
| -rw-r--r-- | subagents/progress.lua | 260 |
1 files changed, 260 insertions, 0 deletions
diff --git a/subagents/progress.lua b/subagents/progress.lua new file mode 100644 index 0000000..274e2a8 --- /dev/null +++ b/subagents/progress.lua @@ -0,0 +1,260 @@ +-- 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; a host that predates any of these fields + -- answers nil, and one that predates the whole object cannot be indexed. + local ok, name = pcall(function() + return event.tool_name + end) + if not ok or 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 |
