local panto = require("panto") local uv = require("luv") local M = {} function M.await(label, arm) local co = assert(coroutine.running(), label .. ": await must run inside a tool handler coroutine") local res, fired = nil, false arm(function(...) assert(not fired, label .. ": libuv callback fired twice") fired = true res = table.pack(...) local ok, err = coroutine.resume(co) if not ok then error(err, 0) end end) coroutine.yield() return table.unpack(res, 1, res.n) end function M.await_n(label, n, arm) local co = assert(coroutine.running(), label .. ": await_n must run inside a tool handler coroutine") local remaining, resumed = n, false local function signal() if remaining <= 0 then return end remaining = remaining - 1 if remaining > 0 or resumed then return end resumed = true local ok, err = coroutine.resume(co) if not ok then io.stderr:write(label .. " tool: resume failed: " .. tostring(err) .. "\n") end end arm(signal) coroutine.yield() end function M.fs_stat(path) return M.await("fs_stat", function(resolve) uv.fs_stat(path, resolve) end) end function M.fs_open(path, flags, mode) return M.await("fs_open", function(resolve) uv.fs_open(path, flags, mode, resolve) end) end function M.fs_fstat(fd) return M.await("fs_fstat", function(resolve) uv.fs_fstat(fd, resolve) end) end function M.fs_read(fd, size, offset) return M.await("fs_read", function(resolve) uv.fs_read(fd, size, offset, resolve) end) end function M.fs_write(fd, data, offset) return M.await("fs_write", function(resolve) uv.fs_write(fd, data, offset, resolve) end) end function M.fs_close(fd) return M.await("fs_close", function(resolve) uv.fs_close(fd, resolve) end) end function M.fs_mkdir(path, mode) return M.await("fs_mkdir", function(resolve) uv.fs_mkdir(path, mode, resolve) end) end function M.fs_opendir(path, batch) return M.await("fs_opendir", function(resolve) uv.fs_opendir(path, resolve, batch) end) end function M.fs_readdir(dir) return M.await("fs_readdir", function(resolve) uv.fs_readdir(dir, resolve) end) end function M.fs_closedir(dir) return M.await("fs_closedir", function(resolve) uv.fs_closedir(dir, resolve) end) end function M.dirname(path) local slash = path:find("/[^/]*$") if not slash or slash == 1 then return nil end return path:sub(1, slash - 1) end function M.mkdir_p_async(path) if path == "" or path == "." or path == "/" then return true end local cur = (path:sub(1, 1) == "/") and "" or "." for piece in string.gmatch(path, "[^/]+") do cur = cur .. "/" .. piece local err = M.fs_mkdir(cur, tonumber("755", 8)) if err and not tostring(err):find("EEXIST") then return nil, err end end return true end function M.mkdir_p_sync(path) if path == "" or path == "." or path == "/" then return true end local cur = (path:sub(1, 1) == "/") and "" or "." for piece in string.gmatch(path, "[^/]+") do cur = cur .. "/" .. piece local ok, err, name = uv.fs_mkdir(cur, tonumber("755", 8)) if not ok and name ~= "EEXIST" then return nil, err end end return true end function M.panto_home() local xdg = os.getenv("XDG_DATA_HOME") if xdg and xdg ~= "" then return xdg .. "/panto" end local home = os.getenv("HOME") if home and home ~= "" then return home .. "/.local/share/panto" end return nil end function M.fresh_spill_path() local base = M.panto_home() if not base then return nil, "no XDG_DATA_HOME / HOME in env" end local dir = base .. "/shell-output" local ok, err = M.mkdir_p_sync(dir) if not ok then return nil, err end local sec, usec = uv.gettimeofday() return string.format("%s/%d-%06d-%d.txt", dir, sec, usec, uv.os_getpid()) end local ok_json, json = pcall(require, "dkjson") if not ok_json then ok_json, json = pcall(require, "luarocks.vendor.dkjson") end function M.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 states_by_index = {} local states_by_id = {} local index_by_id = {} local collapsed_tail_lines = 8 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) local out = {} for raw in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do if raw == "" then out[#out + 1] = "" else local line = raw while #line > width do local cut = width local sp = line:sub(1, width):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 component(st, body_fn) 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(st.header_text or st.name or "tool") lines[#lines + 1] = " " .. spaces(inner) .. " " local body = (body_fn and body_fn(st)) or st.output or "(...)" local body_lines = wrap_text(body, inner) local start = 1 if st.collapsed and #body_lines > collapsed_tail_lines then add("...") start = #body_lines - collapsed_tail_lines + 1 end for i = start, #body_lines do local l = body_lines[i] lines[#lines + 1] = " " .. l .. spaces(inner - #l + 1) end lines[#lines + 1] = "" return lines end, } end function M.install_renderer(tool_name, header_fn, body_fn) 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 = (e.id and states_by_id[e.id]) or (idx and states_by_index[idx]) or nil 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 e.collapsed ~= nil then st.collapsed = e.collapsed end if st.collapsed == nil then st.collapsed = true end st.header_text = header_fn and header_fn(st) or st.name 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 name ~= tool_name then return end e:set_component(component(state_for_event(e), body_fn)) 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) panto.ext.on("tool_collapse", on_tool_event) end return M