From 7a31ae2163f4ce1c6807da1737203d1063839c14 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 31 Aug 2026 10:17:11 -0600 Subject: Show executed Lua in subagent progress boards Render subagents.lua source in both collapsed and expanded progress views so inspection-only calls remain understandable and survive session replay. --- spec/test_progress.lua | 33 +++++++++++++++++++++++++++ subagents/progress.lua | 60 +++++++++++++++++++++++++++++++++++++++++++++++--- 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 -- cgit v1.3