summaryrefslogtreecommitdiff
path: root/subagents/progress.lua
blob: 274e2a8e16cfd48c9920a2d194a825caa84a9437 (plain)
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
259
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