summaryrefslogtreecommitdiff
path: root/agent/extensions
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-15 22:11:53 -0600
committert <t@tjp.lol>2026-06-16 11:17:14 -0600
commitf8c6d45755acb5abf9ac68931268b7945da0fae0 (patch)
treea88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /agent/extensions
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'agent/extensions')
-rw-r--r--agent/extensions/std_render.lua193
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)