summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--spec/test_progress.lua33
-rw-r--r--subagents/progress.lua60
2 files changed, 90 insertions, 3 deletions
diff --git a/spec/test_progress.lua b/spec/test_progress.lua
index c8bf684..3c1ea33 100644
--- a/spec/test_progress.lua
+++ b/spec/test_progress.lua
@@ -295,6 +295,39 @@ return {
progress.reset()
end },
+ { "subagents.lua boards always show the executed source with real newlines", function()
+ if not pcall(require, "dkjson") then return "skip", "dkjson is not installed" end
+ progress.reset()
+ local source = "local w = subagents.workflows[\"workflow-1\"]\n\tif w then\n\t\treturn w.status\n\tend\nreturn \"gone\""
+ local component
+ progress.claim({
+ id = "call-source",
+ tool_name = "subagents.lua",
+ collapsed = true,
+ input = require("dkjson").encode({ source = source }),
+ set_component = function(_, value)
+ component = value
+ return { invalidate = function() end, alive = function() return true end }
+ end,
+ })
+ progress.bind({ tool_call_id = "call-source" })
+
+ -- An inspection call starts no children, so the source is the only
+ -- thing this entry can show.
+ local collapsed = component:render(100)
+ assert(collapsed[2]:find("subagents.lua", 1, true), plain(collapsed))
+ assert(collapsed[3] == " local w = subagents.workflows[\"workflow-1\"]", collapsed[3])
+ assert(collapsed[4] == " if w then", collapsed[4])
+ assert(collapsed[#collapsed] == " \226\128\166", "collapsed source is capped")
+ assert(#collapsed == 2 + 4 + 1, plain(collapsed))
+
+ progress.collapse({ collapsed = false })
+ local expanded = plain(component:render(100))
+ assert(expanded:find("return \"gone\"", 1, true), expanded)
+ assert(not expanded:find("\\n", 1, true), "newlines must render as newlines")
+ progress.reset()
+ end },
+
{ "expanded history includes streamed assistant text and available tool call fields", function()
progress.reset()
local component = board("call-tools", false)
diff --git a/subagents/progress.lua b/subagents/progress.lua
index aee198b..7cad2fa 100644
--- a/subagents/progress.lua
+++ b/subagents/progress.lua
@@ -51,6 +51,35 @@ local function display_tool_name(name)
return type(name) == "string" and name:gsub("__", ".") or "tool"
end
+-- A board replaces the whole tool block, so the Lua a `subagents.lua` call ran
+-- would otherwise never be visible — least of all for an inspection call that
+-- starts no children and therefore has no cards at all. `tool_call_complete`
+-- carries the raw input JSON, which a replayed entry gets too, so the source
+-- survives /resume without any handler-side bookkeeping.
+local function decode_input(text)
+ if type(text) ~= "string" or text == "" then return nil end
+ local ok, panto = pcall(require, "panto")
+ local json = ok and type(panto) == "table" and type(panto.ext) == "table" and panto.ext.json or nil
+ if type(json) ~= "table" or type(json.decode) ~= "function" then
+ local dk_ok, dkjson = pcall(require, "dkjson")
+ json = dk_ok and type(dkjson) == "table" and dkjson or nil
+ end
+ if type(json) ~= "table" or type(json.decode) ~= "function" then return nil end
+ local decoded_ok, value = pcall(json.decode, text)
+ if not decoded_ok or type(value) ~= "table" then return nil end
+ return value
+end
+
+local function source_of(tool_name, input)
+ if tool_name ~= "subagents.lua" then return nil end
+ local decoded = decode_input(input)
+ local source = decoded and decoded.source
+ if type(source) ~= "string" or source == "" then return nil end
+ -- Tabs are stripped to a single space by sanitization, which would flatten
+ -- indented code; widen them first so the listing keeps its shape.
+ return sanitize_multiline((source:gsub("\t", " "))):sub(1, MAX_PRESENTATION_BYTES)
+end
+
local function wrap(text, width, emit)
for _, row in ipairs(require("panto").text.wrap(sanitize(text), math.max(1, width))) do
emit(row)
@@ -237,11 +266,34 @@ local function body_lines(card, width, collapsed)
return out
end
+-- The executed source, verbatim: real newlines, one rendered row per wrapped
+-- source line. Collapsed shows the head of it, expanded the whole thing.
+local function source_lines(board, width, collapsed)
+ local limit = collapsed and COLLAPSED_LINES or MAX_PRESENTATION_LINES
+ local out, truncated = {}, false
+ local function add(row)
+ if #out < limit then out[#out + 1] = BODY_INDENT .. row else truncated = true end
+ end
+ for line in (board.source .. "\n"):gmatch("(.-)\n") do
+ if truncated then break end
+ wrap(line, width, add)
+ end
+ if truncated then out[#out + 1] = BODY_INDENT .. "…" end
+ return out
+end
+
local function render(board, width)
local out = {}
width = math.floor(tonumber(width) or 0)
- if #board.cards == 0 or width <= #BODY_INDENT then return out end
+ if width <= #BODY_INDENT then return out end
+ local source = board.source and source_lines(board, width - #BODY_INDENT, board.collapsed) or nil
+ if #board.cards == 0 and not source then return out end
out[#out + 1] = ""
+ if source then
+ out[#out + 1] = require("panto").text.truncate(
+ sanitize("⚙ " .. display_tool_name(board.tool_name)), width)
+ for _, line in ipairs(source) do out[#out + 1] = line end
+ end
for _, card in ipairs(board.cards) do
local sid = card.sid ~= "" and (" " .. card.sid:sub(1, sid_width) ..
(sid_width < #card.sid and "…" or "")) or ""
@@ -277,9 +329,11 @@ local function prune_boards()
end
end
-local function new_board(key)
+local function new_board(key, tool_name, source)
local board = {
key = key,
+ tool_name = tool_name,
+ source = source,
cards = {},
handle = nil,
collapsed = tools_collapsed,
@@ -298,7 +352,7 @@ function M.claim(event)
if type(event.set_component) ~= "function" then return end
if type(event.collapsed) == "boolean" then tools_collapsed = event.collapsed end
local key = event.id or event.tool_call_id or name
- local board = new_board(key)
+ local board = new_board(key, name, source_of(name, event.input))
local attached, handle = pcall(event.set_component, event, board.component)
if not attached then return end
board.handle = handle