diff options
| author | t <t@tjp.lol> | 2026-06-15 22:11:53 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-16 11:17:14 -0600 |
| commit | f8c6d45755acb5abf9ac68931268b7945da0fae0 (patch) | |
| tree | a88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /agent/extensions | |
| parent | 8206642d3a1736aaf928a5b9a732e747f5294228 (diff) | |
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'agent/extensions')
| -rw-r--r-- | agent/extensions/std_render.lua | 193 |
1 files changed, 193 insertions, 0 deletions
diff --git a/agent/extensions/std_render.lua b/agent/extensions/std_render.lua new file mode 100644 index 0000000..d82ccfa --- /dev/null +++ b/agent/extensions/std_render.lua @@ -0,0 +1,193 @@ +-- Rendering components for the bundled std.* tools. +-- +-- The Zig TUI core deliberately renders tools generically. Tool-specific +-- presentation belongs beside the tool implementation and is installed through +-- the extension event system. + +local panto = require("panto") + +local ok_json, json = pcall(require, "dkjson") +if not ok_json then + ok_json, json = pcall(require, "luarocks.vendor.dkjson") +end + +local states_by_index = {} +local states_by_id = {} +local index_by_id = {} + +local function decode(s) + if type(s) ~= "string" or s == "" or not ok_json then return nil end + local ok, obj = pcall(json.decode, s) + if ok and type(obj) == "table" then return obj end + return nil +end + +local function short_name(name) + if type(name) ~= "string" then return nil end + return name:match("^std%.(.+)$") or name:match("^std__([%w_%-]+)$") or name +end + +local function is_ours(name) + local s = short_name(name) + return s == "read" or s == "write" or s == "shell" or s == "edit" +end + +local function spaces(n) return string.rep(" ", math.max(0, n or 0)) end + +local function wrap_text(text, width) + width = math.max(1, width or 1) + text = tostring(text or "") + local out = {} + for raw in (text .. "\n"):gmatch("(.-)\n") do + if raw == "" then + out[#out + 1] = "" + else + local line = raw + while #line > width do + local cut = width + local prefix = line:sub(1, width) + local sp = prefix:match("^.*() %S") + if sp and sp > 1 then cut = sp - 1 end + out[#out + 1] = line:sub(1, cut) + line = line:sub(cut + 1):gsub("^%s+", "") + end + out[#out + 1] = line + end + end + if #out == 0 then out[1] = "" end + return out +end + +local function quote(v) + if v == nil or v == "" then return nil end + return tostring(v) +end + +local function derive_header(st) + local name = short_name(st.name) or st.name or "?" + local obj = decode(st.input) + if name == "read" and obj then + local path = quote(obj.path) + if path then + if type(obj.offset) == "number" and type(obj.limit) == "number" then + return string.format("read %s (lines %d-%d)", path, obj.offset, obj.offset + obj.limit - 1) + elseif type(obj.offset) == "number" then + return string.format("read %s (from line %d)", path, obj.offset) + end + return "read " .. path + end + elseif name == "write" and obj and quote(obj.path) then + return "write " .. quote(obj.path) + elseif name == "shell" and obj and quote(obj.command) then + local tail = "" + if quote(obj.cwd) then tail = tail .. " cwd=" .. quote(obj.cwd) end + if type(obj.timeout) == "number" then tail = tail .. string.format(" timeout=%ds", obj.timeout) end + return "shell $ " .. quote(obj.command) .. tail + elseif name == "edit" and obj and quote(obj.path) then + local n = type(obj.edits) == "table" and #obj.edits or 0 + if n == 1 then return "edit " .. quote(obj.path) .. " (1 edit)" end + if n > 1 then return string.format("edit %s (%d edits)", quote(obj.path), n) end + return "edit " .. quote(obj.path) + end + return string.format("tool (%s) %s", st.name or "?", st.input or "") +end + +local function header(st) + return st.header_text or derive_header(st) +end + +local function edit_diff(input) + local obj = decode(input) + if not obj or type(obj.edits) ~= "table" then return nil end + local out = {} + for _, e in ipairs(obj.edits) do + if type(e) == "table" then + if type(e.old) == "string" then + for l in (e.old .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "-" .. l end end + end + if type(e.new) == "string" then + for l in (e.new .. "\n"):gmatch("(.-)\n") do if l ~= "" then out[#out + 1] = "+" .. l end end + end + end + end + return table.concat(out, "\n") +end + +local function component(st) + return { + render = function(_, width) + local inner = math.max(1, (width or 1) - 2) + local lines = { "" } + local function add(s) + for _, l in ipairs(wrap_text(s, inner)) do + lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) + end + end + add(header(st)) + lines[#lines + 1] = " " .. spaces(inner) .. " " + local body + if short_name(st.name) == "edit" and st.output then + body = edit_diff(st.input) + end + body = body or st.output or "(…)" + add(body) + lines[#lines + 1] = "" + return lines + end, + } +end + +local function state_for_event(e) + local idx = e.index + if not idx and e.id then idx = index_by_id[e.id] end + + local st = nil + if e.id then st = states_by_id[e.id] end + if not st and idx then st = states_by_index[idx] end + + -- Block indexes are per-turn/per-message stream positions and are reused + -- across later assistant turns. Tool-call ids, however, are unique for the + -- actual call. If a new call reuses an old index, discard the old render + -- state; otherwise the pending component temporarily shows the previous + -- tool's output until the new result arrives. + if st and e.id and st.id and st.id ~= e.id then + st = nil + end + + if not st then + st = { index = idx, input = "" } + end + if idx then + st.index = idx + states_by_index[idx] = st + end + if e.tool_name then st.name = e.tool_name end + if e.id then + st.id = e.id + index_by_id[e.id] = idx or st.index + states_by_id[e.id] = st + end + if e.input then st.input = e.input end + if e.delta then st.input = (st.input or "") .. e.delta end + if e.output then st.output = e.output end + if st.name or st.input ~= "" then st.header_text = derive_header(st) end + return st +end + +local function on_tool_event(e) + local name = e.tool_name + if not name and e.id then + local idx = index_by_id[e.id] + local st = idx and states_by_index[idx] + name = st and st.name + end + if not is_ours(name) then return end + local st = state_for_event(e) + if not st then return end + e:set_component(component(st)) +end + +panto.ext.on("tool_details", on_tool_event) +panto.ext.on("tool_delta", on_tool_event) +panto.ext.on("tool_call_complete", on_tool_event) +panto.ext.on("tool_result", on_tool_event) |
