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 | |
| parent | 8206642d3a1736aaf928a5b9a732e747f5294228 (diff) | |
display fixes: markdown rendering, tool-specific components
| -rw-r--r-- | agent/extensions/std_render.lua | 193 | ||||
| -rw-r--r-- | agent/tools/edit.lua | 297 | ||||
| -rw-r--r-- | agent/tools/read.lua | 351 | ||||
| -rw-r--r-- | agent/tools/shell.lua | 40 | ||||
| -rw-r--r-- | agent/tools/write.lua | 101 | ||||
| -rw-r--r-- | build.zig | 20 | ||||
| -rw-r--r-- | build.zig.zon | 5 | ||||
| -rw-r--r-- | libpanto-c/src/lib.zig | 5 | ||||
| -rw-r--r-- | libpanto-lua/src/module.zig | 1 | ||||
| -rw-r--r-- | libpanto/src/agent.zig | 61 | ||||
| -rw-r--r-- | libpanto/src/pricing.zig | 87 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 11 | ||||
| -rw-r--r-- | libpanto/src/stream.zig | 5 | ||||
| -rw-r--r-- | src/lua_event_bridge.zig | 82 | ||||
| -rw-r--r-- | src/main.zig | 5 | ||||
| -rw-r--r-- | src/markdown.zig | 332 | ||||
| -rw-r--r-- | src/pricing_format.zig | 214 | ||||
| -rw-r--r-- | src/tui_app.zig | 245 | ||||
| -rw-r--r-- | src/tui_components.zig | 526 | ||||
| -rw-r--r-- | src/tui_theme.zig | 13 | ||||
| -rw-r--r-- | tmp/pi_session_cost_analysis.json | 288 |
21 files changed, 2112 insertions, 770 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) diff --git a/agent/tools/edit.lua b/agent/tools/edit.lua index ec96aa6..b32516a 100644 --- a/agent/tools/edit.lua +++ b/agent/tools/edit.lua @@ -10,8 +10,39 @@ -- All matches are evaluated against the original file, not -- progressively against the result of earlier entries. +local panto = require("panto") local uv = require("luv") +local tool = { + name = "std.edit", + description = "Apply exact-text replacements to a file. Each entry's `old` must match exactly once in the original file (not progressively). Edits whose ranges overlap, match zero times, or match multiple times reject the entire call — no partial edits, file untouched. Keep `old` minimal but unique; widen with surrounding context if needed.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + edits = { + type = "array", + description = "Replacements applied atomically.", + minItems = 1, + items = { + type = "object", + properties = { + old = { type = "string", description = "Exact substring to find; must appear exactly once." }, + new = { type = "string", description = "Replacement text (may be empty to delete)." }, + }, + required = { "old", "new" }, + }, + }, + }, + required = { "path", "edits" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- --------------------------------------------------------------------------- -- Coroutine-synchronous libuv wrappers. panto runs each tool handler in -- its own coroutine and drives a single `uv.run()` to completion; a @@ -86,170 +117,148 @@ local function write_all(path, content) return true, nil end -return { - name = "std.edit", - description = "Apply exact-text replacements to a file. Each entry's `old` must match exactly once in the original file (not progressively). Edits whose ranges overlap, match zero times, or match multiple times reject the entire call — no partial edits, file untouched. Keep `old` minimal but unique; widen with surrounding context if needed.", - schema = { - type = "object", - properties = { - path = { type = "string", description = "Path to the file (relative or absolute)." }, - edits = { - type = "array", - description = "Replacements applied atomically.", - minItems = 1, - items = { - type = "object", - properties = { - old = { type = "string", description = "Exact substring to find; must appear exactly once." }, - new = { type = "string", description = "Replacement text (may be empty to delete)." }, - }, - required = { "old", "new" }, - }, - }, - }, - required = { "path", "edits" }, - }, - handler = function(input) - local path = input.path - local edits = input.edits +tool.handler = function(input) + local path = input.path + local edits = input.edits - if type(path) ~= "string" or path == "" then - return "Error: `path` must be a non-empty string." - end - if type(edits) ~= "table" or #edits == 0 then - return "Error: `edits` must be a non-empty array." - end + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if type(edits) ~= "table" or #edits == 0 then + return "Error: `edits` must be a non-empty array." + end - -- Load the file. - local original, read_err = read_all(path) - if not original then - return "Error: " .. (read_err or ("could not read " .. path)) - end + -- Load the file. + local original, read_err = read_all(path) + if not original then + return "Error: " .. (read_err or ("could not read " .. path)) + end - -- Validate every entry first, collecting the byte-range each - -- `before` resolves to. We need this in two passes: - -- 1. uniqueness check (count occurrences) - -- 2. overlap check (compare resolved ranges pairwise) - local problems = {} -- list of "edit #N: ..." strings - local ranges = {} -- per-edit { start_byte, end_byte } or nil + -- Validate every entry first, collecting the byte-range each + -- `before` resolves to. We need this in two passes: + -- 1. uniqueness check (count occurrences) + -- 2. overlap check (compare resolved ranges pairwise) + local problems = {} -- list of "edit #N: ..." strings + local ranges = {} -- per-edit { start_byte, end_byte } or nil - for i, e in ipairs(edits) do - if type(e) ~= "table" then - problems[#problems + 1] = string.format( - "edit #%d: entry is not an object", i - ) - ranges[i] = nil - elseif type(e.old) ~= "string" or type(e.new) ~= "string" then + for i, e in ipairs(edits) do + if type(e) ~= "table" then + problems[#problems + 1] = string.format( + "edit #%d: entry is not an object", i + ) + ranges[i] = nil + elseif type(e.old) ~= "string" or type(e.new) ~= "string" then + problems[#problems + 1] = string.format( + "edit #%d: `old` and `new` must both be strings", i + ) + ranges[i] = nil + elseif e.old == "" then + problems[#problems + 1] = string.format( + "edit #%d: `old` is the empty string (would be ambiguous)", i + ) + ranges[i] = nil + else + -- string.find with `plain = true` skips pattern parsing, + -- so the search is literal. Count occurrences by walking. + local count = 0 + local first_start, first_end = nil, nil + local search_from = 1 + while true do + local s, ee = string.find(original, e.old, search_from, true) + if not s then break end + count = count + 1 + if count == 1 then + first_start, first_end = s, ee + end + search_from = ee + 1 + end + if count == 0 then + -- Truncate the previewed snippet so the error stays + -- legible in tool output. + local snippet = e.old + if #snippet > 80 then + snippet = snippet:sub(1, 77) .. "..." + end problems[#problems + 1] = string.format( - "edit #%d: `old` and `new` must both be strings", i + "edit #%d: `old` not found in file. Snippet: %q", + i, snippet ) ranges[i] = nil - elseif e.old == "" then + elseif count > 1 then problems[#problems + 1] = string.format( - "edit #%d: `old` is the empty string (would be ambiguous)", i + "edit #%d: `old` matches %d times (need exactly 1). " .. + "Widen with surrounding context to disambiguate.", + i, count ) ranges[i] = nil else - -- string.find with `plain = true` skips pattern parsing, - -- so the search is literal. Count occurrences by walking. - local count = 0 - local first_start, first_end = nil, nil - local search_from = 1 - while true do - local s, ee = string.find(original, e.old, search_from, true) - if not s then break end - count = count + 1 - if count == 1 then - first_start, first_end = s, ee - end - search_from = ee + 1 - end - if count == 0 then - -- Truncate the previewed snippet so the error stays - -- legible in tool output. - local snippet = e.old - if #snippet > 80 then - snippet = snippet:sub(1, 77) .. "..." - end - problems[#problems + 1] = string.format( - "edit #%d: `old` not found in file. Snippet: %q", - i, snippet - ) - ranges[i] = nil - elseif count > 1 then - problems[#problems + 1] = string.format( - "edit #%d: `old` matches %d times (need exactly 1). " .. - "Widen with surrounding context to disambiguate.", - i, count - ) - ranges[i] = nil - else - ranges[i] = { first_start, first_end } - end + ranges[i] = { first_start, first_end } end end + end - -- Overlap check (only meaningful if we got this far without - -- per-edit problems; otherwise the user already has plenty to - -- fix and overlap noise would be confusing). - if #problems == 0 then - for i = 1, #ranges do - for j = i + 1, #ranges do - local a, b = ranges[i], ranges[j] - if a and b and not (a[2] < b[1] or b[2] < a[1]) then - problems[#problems + 1] = string.format( - "edits #%d and #%d target overlapping regions " .. - "(bytes %d-%d and %d-%d). Combine them into one " .. - "edit with a wider `old`.", - i, j, a[1], a[2], b[1], b[2] - ) - end + -- Overlap check (only meaningful if we got this far without + -- per-edit problems; otherwise the user already has plenty to + -- fix and overlap noise would be confusing). + if #problems == 0 then + for i = 1, #ranges do + for j = i + 1, #ranges do + local a, b = ranges[i], ranges[j] + if a and b and not (a[2] < b[1] or b[2] < a[1]) then + problems[#problems + 1] = string.format( + "edits #%d and #%d target overlapping regions " .. + "(bytes %d-%d and %d-%d). Combine them into one " .. + "edit with a wider `old`.", + i, j, a[1], a[2], b[1], b[2] + ) end end end + end - if #problems > 0 then - return "Error: edit rejected (" .. #problems .. " issue" .. - (#problems == 1 and "" or "s") .. "); file not modified.\n" .. - " - " .. table.concat(problems, "\n - ") .. "\n" - end + if #problems > 0 then + return "Error: edit rejected (" .. #problems .. " issue" .. + (#problems == 1 and "" or "s") .. "); file not modified.\n" .. + " - " .. table.concat(problems, "\n - ") .. "\n" + end - -- Apply edits. We sort by start position ascending and rebuild - -- the file from non-overlapping slices. This avoids the O(N*M) - -- repeated-substring-substitution and keeps each edit's - -- `before` matched against the original (not the in-progress - -- result). - local order = {} - for i = 1, #edits do order[i] = i end - table.sort(order, function(a, b) - return ranges[a][1] < ranges[b][1] - end) + -- Apply edits. We sort by start position ascending and rebuild + -- the file from non-overlapping slices. This avoids the O(N*M) + -- repeated-substring-substitution and keeps each edit's + -- `before` matched against the original (not the in-progress + -- result). + local order = {} + for i = 1, #edits do order[i] = i end + table.sort(order, function(a, b) + return ranges[a][1] < ranges[b][1] + end) - local out = {} - local cursor = 1 - for _, idx in ipairs(order) do - local r = ranges[idx] - if r[1] > cursor then - out[#out + 1] = original:sub(cursor, r[1] - 1) - end - out[#out + 1] = edits[idx].new - cursor = r[2] + 1 - end - if cursor <= #original then - out[#out + 1] = original:sub(cursor) + local out = {} + local cursor = 1 + for _, idx in ipairs(order) do + local r = ranges[idx] + if r[1] > cursor then + out[#out + 1] = original:sub(cursor, r[1] - 1) end - local new_content = table.concat(out) + out[#out + 1] = edits[idx].new + cursor = r[2] + 1 + end + if cursor <= #original then + out[#out + 1] = original:sub(cursor) + end + local new_content = table.concat(out) - -- Write back. - local ok, write_err = write_all(path, new_content) - if not ok then - return "Error: file read OK but write failed: " .. - tostring(write_err) - end + -- Write back. + local ok, write_err = write_all(path, new_content) + if not ok then + return "Error: file read OK but write failed: " .. + tostring(write_err) + end - return string.format( - "Applied %d edit%s to %s. File is now %d bytes (was %d).", - #edits, (#edits == 1 and "" or "s"), path, #new_content, #original - ) - end, -} + return string.format( + "Applied %d edit%s to %s. File is now %d bytes (was %d).", + #edits, (#edits == 1 and "" or "s"), path, #new_content, #original + ) +end + +return tool diff --git a/agent/tools/read.lua b/agent/tools/read.lua index 0328efc..f691f44 100644 --- a/agent/tools/read.lua +++ b/agent/tools/read.lua @@ -1,3 +1,25 @@ +local panto = require("panto") +local uv = require("luv") + +local tool = { + name = "std.read", + description = "Read a file from disk. Output is truncated to the first 50KB or 2000 lines, whichever is hit first; directories return a clearly tagged non-recursive listing. Use offset/limit to slice. Works with images and PDFs.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file or directory (relative or absolute)." }, + offset = { type = "integer", description = "1-based line to start at.", minimum = 1 }, + limit = { type = "integer", description = "Maximum lines to return.", minimum = 1 }, + }, + required = { "path" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- Read a file from disk and return its text verbatim. -- -- Output cap: 50 KB and 2000 lines, whichever is hit first. Large @@ -13,8 +35,6 @@ -- If `path` names a directory, return a clearly tagged non-recursive -- listing of its immediate children instead of an error. -local uv = require("luv") - -- Minimal magic-byte sniff: binary attachment (image/PDF) vs. text. -- We only decide *whether* the bytes are an attachment; libpanto does the -- precise type detection, resizing, and encoding from the raw bytes. Keep @@ -216,197 +236,186 @@ local function render_directory_listing(path, offset, limit) return body end -return { - name = "std.read", - description = "Read a file from disk. Output is truncated to the first 50KB or 2000 lines, whichever is hit first; directories return a clearly tagged non-recursive listing. Use offset/limit to slice. Works with images and PDFs.", - schema = { - type = "object", - properties = { - path = { type = "string", description = "Path to the file or directory (relative or absolute)." }, - offset = { type = "integer", description = "1-based line to start at.", minimum = 1 }, - limit = { type = "integer", description = "Maximum lines to return.", minimum = 1 }, - }, - required = { "path" }, - }, - handler = function(input) - local path = input.path - local offset = input.offset or 1 - local limit = input.limit -- nil means "to EOF" +tool.handler = function(input) + local path = input.path + local offset = input.offset or 1 + local limit = input.limit -- nil means "to EOF" - if type(path) ~= "string" or path == "" then - return "Error: `path` must be a non-empty string." - end - if limit ~= nil and limit < 1 then - return string.format("Error: limit (%d) must be >= 1.", limit) - end + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if limit ~= nil and limit < 1 then + return string.format("Error: limit (%d) must be >= 1.", limit) + end - local stat_err, stat = fs_stat(path) - if not stat then - return "Error: " .. (stat_err or ("could not stat " .. path)) - end - if stat.type == "directory" then - return render_directory_listing(path, offset, limit) - end + local stat_err, stat = fs_stat(path) + if not stat then + return "Error: " .. (stat_err or ("could not stat " .. path)) + end + if stat.type == "directory" then + return render_directory_listing(path, offset, limit) + end - local open_err, fd = fs_open(path, "r", 0) - if not fd then - return "Error: " .. (open_err or ("could not open " .. path)) - end + local open_err, fd = fs_open(path, "r", 0) + if not fd then + return "Error: " .. (open_err or ("could not open " .. path)) + end - -- Binary attachment path. We do the *minimal* magic-byte sniff - -- here — only enough to decide "binary attachment vs. text". If - -- it's an attachment, we return the raw file bytes; libpanto does - -- the real work (precise type detection, resizing, encoding). - do - local herr, header = fs_read(fd, 16, 0) - if not herr and header and #header > 0 and is_attachment(header) then - local whole, rerr = (function() - local chunks = {} - local off = 0 - while true do - local e, d = fs_read(fd, READ_CHUNK_SIZE, off) - if e then return nil, e end - if d == nil or #d == 0 then break end - chunks[#chunks + 1] = d - off = off + #d - end - return table.concat(chunks) - end)() - fs_close(fd) - if not whole then - return "Error: read failed: " .. tostring(rerr) + -- Binary attachment path. We do the *minimal* magic-byte sniff + -- here — only enough to decide "binary attachment vs. text". If + -- it's an attachment, we return the raw file bytes; libpanto does + -- the real work (precise type detection, resizing, encoding). + do + local herr, header = fs_read(fd, 16, 0) + if not herr and header and #header > 0 and is_attachment(header) then + local whole, rerr = (function() + local chunks = {} + local off = 0 + while true do + local e, d = fs_read(fd, READ_CHUNK_SIZE, off) + if e then return nil, e end + if d == nil or #d == 0 then break end + chunks[#chunks + 1] = d + off = off + #d end - return { - text = string.format("[read %s as binary attachment]", path), - attachments = { { data = whole } }, - } + return table.concat(chunks) + end)() + fs_close(fd) + if not whole then + return "Error: read failed: " .. tostring(rerr) end + return { + text = string.format("[read %s as binary attachment]", path), + attachments = { { data = whole } }, + } end + end - local parts = {} - local emitted_lines = 0 - local emitted_bytes = 0 - local lineno = 0 - local truncated_reason = nil - local stopped_at_line = nil - local effective_line_cap = MAX_LINES - if limit and limit < effective_line_cap then - effective_line_cap = limit - end + local parts = {} + local emitted_lines = 0 + local emitted_bytes = 0 + local lineno = 0 + local truncated_reason = nil + local stopped_at_line = nil + local effective_line_cap = MAX_LINES + if limit and limit < effective_line_cap then + effective_line_cap = limit + end - local file_offset = 0 - local done = false - local read_err = nil - local saw_any_bytes = false - local saw_trailing_newline = false + local file_offset = 0 + local done = false + local read_err = nil + local saw_any_bytes = false + local saw_trailing_newline = false - local carry = "" - local function process_line(line) - lineno = lineno + 1 - if lineno < offset then - return - end + local carry = "" + local function process_line(line) + lineno = lineno + 1 + if lineno < offset then + return + end - local rendered = line .. "\n" - if emitted_bytes + #rendered > MAX_BYTES then - truncated_reason = "bytes" - stopped_at_line = lineno - done = true - return - end - parts[#parts + 1] = rendered - emitted_bytes = emitted_bytes + #rendered - emitted_lines = emitted_lines + 1 - if emitted_lines >= effective_line_cap then - stopped_at_line = lineno - done = true - end + local rendered = line .. "\n" + if emitted_bytes + #rendered > MAX_BYTES then + truncated_reason = "bytes" + stopped_at_line = lineno + done = true + return end + parts[#parts + 1] = rendered + emitted_bytes = emitted_bytes + #rendered + emitted_lines = emitted_lines + 1 + if emitted_lines >= effective_line_cap then + stopped_at_line = lineno + done = true + end + end - while not done do - local err, data = fs_read(fd, READ_CHUNK_SIZE, file_offset) - if err then - read_err = err - break - end - -- luv's `fs_read` signals EOF by returning the empty - -- string, NOT nil (nil only accompanies an error). Treat - -- both nil and "" as EOF; otherwise the loop re-reads at - -- the same offset forever, pinning a core at 100%. - if data == nil or #data == 0 then - if #carry > 0 then - process_line(carry) - carry = "" - end - break + while not done do + local err, data = fs_read(fd, READ_CHUNK_SIZE, file_offset) + if err then + read_err = err + break + end + -- luv's `fs_read` signals EOF by returning the empty + -- string, NOT nil (nil only accompanies an error). Treat + -- both nil and "" as EOF; otherwise the loop re-reads at + -- the same offset forever, pinning a core at 100%. + if data == nil or #data == 0 then + if #carry > 0 then + process_line(carry) + carry = "" end + break + end - saw_any_bytes = true - file_offset = file_offset + #data - saw_trailing_newline = data:sub(-1) == "\n" - local chunk = carry .. data - local start = 1 + saw_any_bytes = true + file_offset = file_offset + #data + saw_trailing_newline = data:sub(-1) == "\n" + local chunk = carry .. data + local start = 1 - while not done do - local nl = chunk:find("\n", start, true) - if not nl then break end - local line = chunk:sub(start, nl - 1) - if line:sub(-1) == "\r" then - line = line:sub(1, -2) - end - process_line(line) - start = nl + 1 + while not done do + local nl = chunk:find("\n", start, true) + if not nl then break end + local line = chunk:sub(start, nl - 1) + if line:sub(-1) == "\r" then + line = line:sub(1, -2) end + process_line(line) + start = nl + 1 + end - carry = chunk:sub(start) - if done and truncated_reason == nil and emitted_lines >= effective_line_cap then - truncated_reason = (limit and limit <= MAX_LINES) and "limit" or "lines" - end + carry = chunk:sub(start) + if done and truncated_reason == nil and emitted_lines >= effective_line_cap then + truncated_reason = (limit and limit <= MAX_LINES) and "limit" or "lines" end + end - fs_close(fd) + fs_close(fd) - if read_err then - return "Error: read failed: " .. tostring(read_err) - end + if read_err then + return "Error: read failed: " .. tostring(read_err) + end - if #parts == 0 then - if not saw_any_bytes then - return "(empty file)\n" - end - if offset > lineno then - return string.format( - "Error: offset (%d) is past end of file (%d lines).", - offset, lineno - ) - end - if lineno == 0 and saw_any_bytes and not saw_trailing_newline then - return "(empty file)\n" - end - return "(no lines in requested range)\n" + if #parts == 0 then + if not saw_any_bytes then + return "(empty file)\n" end - - local body = table.concat(parts) - if truncated_reason == "bytes" then - body = body .. string.format( - "\n[truncated: hit %d-byte cap at line %d. " .. - "Call `read` again with `offset = %d` (and a smaller " .. - "`limit` if needed) to continue.]\n", - MAX_BYTES, stopped_at_line, stopped_at_line - ) - elseif truncated_reason == "lines" then - body = body .. string.format( - "\n[truncated: hit %d-line cap at line %d. " .. - "Call `read` again with `offset = %d` to continue.]\n", - MAX_LINES, stopped_at_line, stopped_at_line + 1 - ) - elseif truncated_reason == "limit" then - body = body .. string.format( - "\n[truncated: hit caller's `limit = %d` at line %d. " .. - "Call `read` again with `offset = %d` to continue.]\n", - limit, stopped_at_line, stopped_at_line + 1 + if offset > lineno then + return string.format( + "Error: offset (%d) is past end of file (%d lines).", + offset, lineno ) end - return body - end, -} + if lineno == 0 and saw_any_bytes and not saw_trailing_newline then + return "(empty file)\n" + end + return "(no lines in requested range)\n" + end + + local body = table.concat(parts) + if truncated_reason == "bytes" then + body = body .. string.format( + "\n[truncated: hit %d-byte cap at line %d. " .. + "Call `read` again with `offset = %d` (and a smaller " .. + "`limit` if needed) to continue.]\n", + MAX_BYTES, stopped_at_line, stopped_at_line + ) + elseif truncated_reason == "lines" then + body = body .. string.format( + "\n[truncated: hit %d-line cap at line %d. " .. + "Call `read` again with `offset = %d` to continue.]\n", + MAX_LINES, stopped_at_line, stopped_at_line + 1 + ) + elseif truncated_reason == "limit" then + body = body .. string.format( + "\n[truncated: hit caller's `limit = %d` at line %d. " .. + "Call `read` again with `offset = %d` to continue.]\n", + limit, stopped_at_line, stopped_at_line + 1 + ) + end + return body +end + +return tool diff --git a/agent/tools/shell.lua b/agent/tools/shell.lua index f9be58b..6c0a37c 100644 --- a/agent/tools/shell.lua +++ b/agent/tools/shell.lua @@ -26,12 +26,32 @@ -- a string-returning tool result is shaped). Most shell commands the -- agent runs interleave the two anyway. +local panto = require("panto") local uv = require("luv") local MAX_BYTES = 50 * 1024 -- 50 KB, same as `read` local DEFAULT_TIMEOUT_S = 300 -- 5 minutes local SIGKILL_GRACE_MS = 250 +local tool = { + name = "std.shell", + description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024), + schema = { + type = "object", + properties = { + command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." }, + cwd = { type = "string", description = "Working directory (default: cwd)." }, + timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 }, + }, + required = { "command" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- --------------------------------------------------------------------------- -- Coroutine-synchronous coordination. -- @@ -128,20 +148,7 @@ end -- --------------------------------------------------------------------------- -- Tool body -- --------------------------------------------------------------------------- - -return { - name = "std.shell", - description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024), - schema = { - type = "object", - properties = { - command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." }, - cwd = { type = "string", description = "Working directory (default: cwd)." }, - timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 }, - }, - required = { "command" }, - }, - handler = function(input) +tool.handler = function(input) local command = input.command if type(command) ~= "string" or command == "" then return "Error: `command` must be a non-empty string." @@ -427,5 +434,6 @@ return { end end return table.concat(parts, "\n") - end, -} + end + +return tool diff --git a/agent/tools/write.lua b/agent/tools/write.lua index 42cb85a..144a305 100644 --- a/agent/tools/write.lua +++ b/agent/tools/write.lua @@ -2,8 +2,27 @@ -- description terse because every byte of every tool description rides -- in the system prompt on every turn. +local panto = require("panto") local uv = require("luv") +local tool = { + name = "std.write", + description = "Save content to a path. Overwrites existing files; missing parent directories are created automatically.", + schema = { + type = "object", + properties = { + path = { type = "string", description = "Path to the file (relative or absolute)." }, + content = { type = "string", description = "Exact bytes to write. No newline is added." }, + }, + required = { "path", "content" }, + }, +} + +panto.ext.on("tool", function(e) + if e.tool_name ~= tool.name then return end + e:set_component(e:get_component()) +end) + -- --------------------------------------------------------------------------- -- Coroutine-synchronous libuv wrappers. panto runs each tool handler in -- its own coroutine and drives a single `uv.run()` to completion; a @@ -68,57 +87,47 @@ local function dirname(path) return path:sub(1, slash - 1) end -return { - name = "std.write", - description = "Save content to a path. Overwrites existing files; missing parent directories are created automatically.", - schema = { - type = "object", - properties = { - path = { type = "string", description = "Path to the file (relative or absolute)." }, - content = { type = "string", description = "Exact bytes to write. No newline is added." }, - }, - required = { "path", "content" }, - }, - handler = function(input) - local path = input.path - local content = input.content +tool.handler = function(input) + local path = input.path + local content = input.content - if type(path) ~= "string" or path == "" then - return "Error: `path` must be a non-empty string." - end - if type(content) ~= "string" then - return "Error: `content` must be a string." - end + if type(path) ~= "string" or path == "" then + return "Error: `path` must be a non-empty string." + end + if type(content) ~= "string" then + return "Error: `content` must be a string." + end - local parent = dirname(path) - if parent then - local ok, err = mkdir_p(parent) - if not ok then - return "Error: could not create parent directory " .. parent .. ": " .. tostring(err) - end + local parent = dirname(path) + if parent then + local ok, err = mkdir_p(parent) + if not ok then + return "Error: could not create parent directory " .. parent .. ": " .. tostring(err) end + end - local open_err, fd = fs_open(path, "w", tonumber("644", 8)) - if not fd then - return "Error: " .. (open_err or ("could not open " .. path .. " for writing")) - end + local open_err, fd = fs_open(path, "w", tonumber("644", 8)) + if not fd then + return "Error: " .. (open_err or ("could not open " .. path .. " for writing")) + end - -- A single `fs_write` may short-write; loop until all bytes land. - local offset = 0 - while offset < #content do - local werr, n = fs_write(fd, content:sub(offset + 1), offset) - if not n then - fs_close(fd) - return "Error: write failed: " .. tostring(werr) - end - offset = offset + n + -- A single `fs_write` may short-write; loop until all bytes land. + local offset = 0 + while offset < #content do + local werr, n = fs_write(fd, content:sub(offset + 1), offset) + if not n then + fs_close(fd) + return "Error: write failed: " .. tostring(werr) end + offset = offset + n + end - local close_err = fs_close(fd) - if close_err then - return "Error: close failed: " .. tostring(close_err) - end + local close_err = fs_close(fd) + if close_err then + return "Error: close failed: " .. tostring(close_err) + end - return string.format("Wrote %d bytes to %s.", #content, path) - end, -} + return string.format("Wrote %d bytes to %s.", #content, path) +end + +return tool @@ -34,6 +34,10 @@ pub fn build(b: *std.Build) void { .@"thread-pool" = false, }); + // Markdown parser for TUI rendering. MD4C is a small, permissively + // licensed C CommonMark parser; Zig owns only terminal rendering. + const md4c_dep = b.dependency("md4c", .{}); + // Fetch upstream Lua source (used both for our static library and // staged at runtime as the `include/` headers under $PANTO_HOME). // Reproducibility comes from the content-addressed hash in @@ -102,6 +106,7 @@ pub fn build(b: *std.Build) void { // table for `dlopen`'d rocks to resolve. addLuaSources(exe_mod, lua_src, lua_anchor_path); exe_mod.addIncludePath(lua_src.path("src")); + addMd4c(exe_mod, md4c_dep); // Compile lua.c (the upstream standalone interpreter, ~600 lines) // as a separate static library so we can route the `panto lua` @@ -156,6 +161,7 @@ pub fn build(b: *std.Build) void { })); addLuaSources(test_mod, lua_src, lua_anchor_path); test_mod.addIncludePath(lua_src.path("src")); + addMd4c(test_mod, md4c_dep); test_mod.linkLibrary(lua_repl); const unit_tests = b.addTest(.{ @@ -172,6 +178,20 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&lib_test_step.step); } +fn addMd4c(mod: *std.Build.Module, md4c_dep: *std.Build.Dependency) void { + const cflags = [_][]const u8{ + "-std=c99", + "-Wall", + "-Wextra", + "-Wno-unused-parameter", + }; + mod.addCSourceFile(.{ + .file = md4c_dep.path("src/md4c.c"), + .flags = &cflags, + }); + mod.addIncludePath(md4c_dep.path("src")); +} + /// Compile a thin wrapper around the upstream `lua.c` standalone /// interpreter that exposes `pmain` for panto's `lua` subcommand to /// invoke against a pre-configured `lua_State`. diff --git a/build.zig.zon b/build.zig.zon index 6a04444..54dd772 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -17,6 +17,10 @@ .url = "https://luarocks.github.io/luarocks/releases/luarocks-3.13.0.tar.gz", .hash = "N-V-__8AADZhHwD_K21NLLYGySn7TnLhpLyP8ca2JnXSLgbp", }, + .md4c = .{ + .url = "https://github.com/mity/md4c/archive/refs/tags/release-0.5.2.tar.gz", + .hash = "N-V-__8AABchEAAlECji-MmwhoTDo_4aoyB1HkgVStV-vfho", + }, .toml = .{ .url = "https://gitlab.com/devnw/zig/toml/-/archive/v0.1.4/toml-v0.1.4.tar.gz", .hash = "toml-0.1.4-MHnSh2GWBQCW9SORG0z01zGj-bBlxbb82Itm5eIHNPX5", @@ -26,5 +30,6 @@ "build.zig", "build.zig.zon", "src", + "agent", }, } diff --git a/libpanto-c/src/lib.zig b/libpanto-c/src/lib.zig index 888c6d6..d877abe 100644 --- a/libpanto-c/src/lib.zig +++ b/libpanto-c/src/lib.zig @@ -31,7 +31,7 @@ pub const PantoEffort = enum(c_int) { low = 0, medium, high, xhigh, max }; pub const PantoMessageRole = enum(c_int) { system = 0, user, assistant }; pub const PantoContentBlockTag = enum(c_int) { Text = 0, Thinking, ToolUse, ToolResult, System, CompactionSummary }; pub const PantoSystemMode = enum(c_int) { append = 0, replace }; -pub const PantoEventTag = enum(c_int) { message_start = 0, block_start, tool_details, content_delta, block_complete, message_complete, provider_retry, tool_dispatch_start, tool_dispatch_complete, turn_complete }; +pub const PantoEventTag = enum(c_int) { message_start = 0, block_start, tool_details, content_delta, block_complete, message_complete, provider_retry, tool_dispatch_start, tool_dispatch_result, tool_dispatch_complete, turn_complete }; pub const PantoUsage = extern struct { input: u64, output: u64, cache_read: u64, cache_write: u64, reasoning: u64 }; pub const PantoSessionInfo = extern struct { id: PantoSlice, created: PantoSlice, modified: PantoSlice, last_user_message: PantoSlice, base_url: PantoSlice, model: PantoSlice, message_count: usize, api_style: PantoAPIStyle, reasoning: PantoReasoningEffort }; @@ -53,7 +53,7 @@ pub const PantoCompactionResult = extern struct { compacted: bool, kept_turns: u pub const PantoProviderRetry = extern struct { attempt: usize, max_attempts: usize, delay_ms: u64, error_name: PantoSlice, has_status_code: bool, status_code: u16, has_retry_after_ms: bool, retry_after_ms: u64, message: PantoSlice, compaction: bool }; pub const PantoToolDispatchStart = extern struct { count: usize }; pub const PantoToolDispatchComplete = extern struct { message_index: usize }; -pub const PantoEvent = extern struct { tag: PantoEventTag, data: extern union { message_start: PantoMessageRole, block_start: PantoBlockStart, tool_details: PantoToolDetails, content_delta: PantoContentDelta, block_complete: PantoBlockComplete, message_complete: PantoMessageComplete, provider_retry: PantoProviderRetry, tool_dispatch_start: PantoToolDispatchStart, tool_dispatch_complete: PantoToolDispatchComplete } }; +pub const PantoEvent = extern struct { tag: PantoEventTag, data: extern union { message_start: PantoMessageRole, block_start: PantoBlockStart, tool_details: PantoToolDetails, content_delta: PantoContentDelta, block_complete: PantoBlockComplete, message_complete: PantoMessageComplete, provider_retry: PantoProviderRetry, tool_dispatch_start: PantoToolDispatchStart, tool_dispatch_result: PantoToolDispatchComplete, tool_dispatch_complete: PantoToolDispatchComplete } }; pub const PantoPersistentMessage = extern struct { role: PantoMessageRole, has_usage: bool, usage: PantoUsage, has_metadata: bool, metadata: PantoSlice }; pub const PantoSessionStoreVTable = extern struct { create: *const fn (*anyopaque, **PantoSession) callconv(.c) PantoStatus, @@ -648,6 +648,7 @@ fn marshalEvent(ev: panto.Event) !PantoEvent { .message_complete => |m| .{ .tag = .message_complete, .data = .{ .message_complete = .{ .has_usage = m.usage != null, .usage = if (m.usage) |u| usage(u) else .{ .input = 0, .output = 0, .cache_read = 0, .cache_write = 0, .reasoning = 0 } } } }, .provider_retry => |r| .{ .tag = .provider_retry, .data = .{ .provider_retry = .{ .attempt = r.attempt, .max_attempts = r.max_attempts, .delay_ms = r.delay_ms, .error_name = try dupOut(@errorName(r.err)), .has_status_code = r.status_code != null, .status_code = r.status_code orelse 0, .has_retry_after_ms = r.retry_after_ms != null, .retry_after_ms = r.retry_after_ms orelse 0, .message = try dupOut(r.message orelse ""), .compaction = r.compaction } } }, .tool_dispatch_start => |t| .{ .tag = .tool_dispatch_start, .data = .{ .tool_dispatch_start = .{ .count = t.count } } }, + .tool_dispatch_result => .{ .tag = .tool_dispatch_result, .data = .{ .tool_dispatch_result = .{ .message_index = 0 } } }, .tool_dispatch_complete => .{ .tag = .tool_dispatch_complete, .data = .{ .tool_dispatch_complete = .{ .message_index = 0 } } }, .turn_complete => .{ .tag = .turn_complete, .data = undefined }, }; diff --git a/libpanto-lua/src/module.zig b/libpanto-lua/src/module.zig index 8d07b42..349a59e 100644 --- a/libpanto-lua/src/module.zig +++ b/libpanto-lua/src/module.zig @@ -1827,6 +1827,7 @@ fn pushEvent(L: *c.lua_State, ev: panto.Event) !void { setStringField(L, "type", "tool_dispatch_start"); setIntField(L, "count", @intCast(tds.count)); }, + .tool_dispatch_result => setStringField(L, "type", "tool_dispatch_result"), .tool_dispatch_complete => setStringField(L, "type", "tool_dispatch_complete"), .turn_complete => setStringField(L, "type", "turn_complete"), } diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 0ff231a..8a8a2e2 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -1150,6 +1150,10 @@ pub const Stream = struct { streaming, /// A provider response completed; decide tools-vs-done. after_response, + /// Tool dispatch has been announced to callers; run the blocking + /// dispatch on the next pull so `tool_dispatch_start` is observable + /// before long-running tools complete. + dispatching_tools, /// The turn reached its terminal `turn_complete`. done, /// A failure already propagated; `next()` is poisoned. @@ -1328,12 +1332,25 @@ pub const Stream = struct { return .turn_complete; } - // Dispatch the tool calls, bracketed by boundary events. + // Announce tool dispatch and return immediately. The + // dispatch itself can block for a long time (especially + // with parallel tools); yielding this boundary event first + // lets UIs/renderers show all tool calls as running instead + // of appearing frozen until the slowest tool completes. const count = toolUseCount(last); self._queue.push(.{ .tool_dispatch_start = .{ .count = count } }) catch |e| { self.state = .failed; return e; }; + self.state = .dispatching_tools; + if (self._queue.pop()) |ev| return ev; + }, + .dispatching_tools => { + const conv = &self._agent.conversation; + const last = conv.messages.items[conv.messages.items.len - 1]; + std.debug.assert(last.role == .assistant); + std.debug.assert(Agent.hasToolUseBlock(last)); + self._agent.dispatchToolCalls(last) catch |err| { self.state = .failed; return err; @@ -2394,6 +2411,48 @@ test "runStep dispatches a tool call and loops to a final text turn" { try testing.expectEqualStrings("ok", conv.messages.items[3].content.items[0].Text.items); } +test "Stream emits tool_dispatch_start before running tools" { + const allocator = testing.allocator; + + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{ + .{ .ToolUse = .{ .id = "tc_1", .name = "echo", .input = "hello" } }, + } }, + .{ .blocks = &.{.{ .Text = "ok" }} }, + }; + var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); + h.activate(); + var ns = null_store_mod.NullStore.init(allocator); + const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null); + defer agent.deinit(); + h.seedInto(agent); + agent._open_stream_fn = stub.install(); + + var s = try agent.run(.{ .text = "call a tool" }); + defer s.deinit(); + + const first = (try s.next()).?; + try testing.expect(first == .message_complete); + try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); + + const start = (try s.next()).?; + try testing.expect(start == .tool_dispatch_start); + try testing.expectEqual(@as(usize, 1), start.tool_dispatch_start.count); + // The ToolResult user message must not exist yet; otherwise callers do + // not get a chance to render the tool as running before dispatch blocks. + try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); + + const complete = (try s.next()).?; + try testing.expect(complete == .tool_dispatch_complete); + try testing.expectEqual(@as(usize, 3), agent.conversation.messages.items.len); +} + test "runStep dispatches multiple tool calls in parallel" { const allocator = testing.allocator; diff --git a/libpanto/src/pricing.zig b/libpanto/src/pricing.zig index 7d4e1f8..97dfe38 100644 --- a/libpanto/src/pricing.zig +++ b/libpanto/src/pricing.zig @@ -83,6 +83,13 @@ pub const Pricing = struct { if (r >= @as(f64, @floatFromInt(std.math.maxInt(u64)))) return std.math.maxInt(u64); return @intFromFloat(r); } + + /// Inverse of `fromDollarsPerMtok`: convert the integer representation + /// back to USD per million tokens (the human-friendly unit). Returns + /// 0.0 for the null case. + pub fn toDollarsPerMtok(micro_cents_per_token: u64) f64 { + return @as(f64, @floatFromInt(micro_cents_per_token)) / 100.0; + } }; // ============================================================================= @@ -120,6 +127,86 @@ fn component(tokens: u64, price: ?u64) ?u64 { return tokens *% p; } +/// Add a turn's cost to a running session total, in micro-cents. +/// Saturating `+%` on the total (a session-bucket overflow is +/// catastrophic for a `u64` value, so we pin to max rather than wrap). +/// The poison rule matches `costMicroCents`: any individual turn +/// whose cost is unknown poisons the session total to `null`. +/// +/// This is the single accumulation point for session-level cost +/// display, so the model-switch tolerance lives here: a switch in the +/// middle of a session just means successive `costMicroCents` calls +/// run against different `Pricing` structs (one per `(provider, +/// model)`). The TUI footer's cost pass holds the registry and looks +/// up the right pricing for each turn at the time the turn lands. +pub fn addCost(total: ?u64, turn_cost: ?u64) ?u64 { + const t = total orelse return null; + const c = turn_cost orelse return null; + return t +% c; +} + +test "addCost: accumulates known costs across many turns" { + var s: ?u64 = 0; + s = addCost(s, 1_000_000); // $0.01 + s = addCost(s, 5_000_000); // $0.05 + s = addCost(s, 2_000_000); // $0.02 + try testing.expectEqual(@as(?u64, 8_000_000), s); +} + +test "addCost: a known + unknown + known sequence poisons the total" { + // The poison rule is one-way: once any priced component of any + // turn is unknown, the whole session cost is unknown forever. + var s: ?u64 = 0; + s = addCost(s, 1_000_000); // $0.01 (known) + try testing.expectEqual(@as(?u64, 1_000_000), s); + s = addCost(s, null); // poison! + try testing.expect(s == null); + s = addCost(s, 5_000_000); // still null + try testing.expect(s == null); +} + +test "addCost: tolerates a model switch mid-session (each turn's cost is per-model)" { + // A model switch in the middle of a session: each turn's + // cost is computed against the active model's pricing + // upstream of `addCost`, so the function itself just sees a + // sequence of independent turn costs. The TUI's session-cost + // display sums them all; this is the tolerance: a switch is + // invisible to the accumulator as long as both models have + // pricing entries. + var s: ?u64 = 0; + s = addCost(s, costMicroCents( + .{ .input = 100, .output = 50 }, + .{ .input = 300, .output = 1500 }, + ).?); + // Switch to a different-priced model. + s = addCost(s, costMicroCents( + .{ .input = 200, .output = 100 }, + .{ .input = 100, .output = 500 }, + ).?); + // 100*300 + 50*1500 = 105_000 + // 200*100 + 100*500 = 70_000 + // total = 175_000 micro-cents = $0.00175 -> $0.00 (rounded) + try testing.expectEqual(@as(?u64, 175_000), s); +} + +test "addCost: a switch from a priced model to an unpriced model poisons" { + // The new model has no pricing entry: `costMicroCents` returns + // null (every priced component is null), and `addCost` then + // poisons the session total to null. The cost UP TO the + // switch is "known"; after it the total is "unknown". + var s: ?u64 = 0; + s = addCost(s, costMicroCents( + .{ .input = 100, .output = 50 }, + .{ .input = 300, .output = 1500 }, + ).?); + // Switch to an unpriced model. + s = addCost(s, costMicroCents( + .{ .input = 200, .output = 100 }, + .{}, // no pricing at all + )); + try testing.expect(s == null); +} + // ============================================================================= // Registry // ============================================================================= diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 8a55e47..d547af3 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -218,6 +218,17 @@ pub const Agent = agent_mod.Agent; pub const Pricing = pricing_mod.Pricing; pub const PricingRegistry = pricing_mod.Registry; +/// Compute the cost of a single turn's `Usage` under the given `Pricing`, +/// in micro-cents (1/1,000,000 of a cent per token). Returns null when any +/// priced component of any nonzero category is null (the poison rule — +/// don't pretend a turn with unknown cache pricing is free). Aliased +/// through `libpanto` because the embedder accumulates session totals +/// across potentially-switched models. +pub const costMicroCents = pricing_mod.costMicroCents; +/// Add a turn's micro-cents cost to a running session total. Either +/// side null => result null. The single accumulation point for the +/// per-turn session cost (see `pricing.zig` for why). +pub const addCost = pricing_mod.addCost; // =========================================================================== // Sessions diff --git a/libpanto/src/stream.zig b/libpanto/src/stream.zig index e626f31..64748b2 100644 --- a/libpanto/src/stream.zig +++ b/libpanto/src/stream.zig @@ -70,6 +70,11 @@ pub const Event = union(enum) { /// concurrent tool execution. tool_dispatch_start: ToolDispatchStart, + /// One tool result is available. The payload is a user-role carrier + /// containing exactly one `ToolResult` block, keyed by `tool_use_id`. + /// This may arrive before the aggregate `tool_dispatch_complete` event. + tool_dispatch_result: ToolDispatchComplete, + /// The agent finished dispatching tools and appended a user(ToolResult) /// message to the conversation. `message` is borrowed. tool_dispatch_complete: ToolDispatchComplete, diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig index c4acb1d..24bc6fc 100644 --- a/src/lua_event_bridge.zig +++ b/src/lua_event_bridge.zig @@ -3,7 +3,7 @@ //! This module bridges the native event machinery in `tui_event.zig` to //! Lua: it lets a Lua `panto.ext.on(name, handler)` callback participate in //! the SAME `EventBus` the native side fires, receive a bridged `event` -//! object (`getComponent`/`setComponent` + payload fields), and either +//! object (`get_component`/`set_component` + payload fields), and either //! pass through a native default component, wrap it, or install a //! brand-new component DEFINED IN LUA. //! @@ -401,7 +401,7 @@ pub const EventBridge = struct { /// The native `Handler.callback` for a bridged Lua handler. Builds the /// `event` userdata, calls the Lua function with it (synchronously, under /// a traceback errfunc), and lets the Lua side read/replace the component -/// via `event:getComponent()` / `event:setComponent()`. Errors in the Lua +/// via `event:get_component()` / `event:set_component()`. Errors in the Lua /// handler are logged and swallowed — a broken handler must not abort the /// event dispatch or the render loop. fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void { @@ -444,7 +444,7 @@ fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void { /// Push an `event` userdata onto the stack, carrying a pointer to the /// bridge (which reaches the active `*Event`). Its metatable exposes -/// `getComponent`, `setComponent`, and read-only payload fields via +/// `get_component`, `set_component`, and read-only payload fields via /// `__index`. fn pushEventObject(bridge: *EventBridge) !void { const L = bridge.L; @@ -456,7 +456,7 @@ fn pushEventObject(bridge: *EventBridge) !void { /// Lazily create the `event` userdata metatable (by name, so /// `luaL_checkudata` recognizes it) and leave it on the stack. Its -/// `__index` is a function resolving `getComponent`/`setComponent` and +/// `__index` is a function resolving `get_component`/`set_component` and /// payload fields. fn ensureEventMetatable(L: *c.lua_State) void { // luaL_newmetatable pushes the metatable; returns 1 if freshly created. @@ -476,11 +476,11 @@ fn eventIndexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { if (kptr == null) return 0; const key = kptr[0..klen]; - if (std.mem.eql(u8, key, "getComponent")) { + if (std.mem.eql(u8, key, "get_component")) { c.lua_pushcclosure(L, eventGetComponentThunk, 0); return 1; } - if (std.mem.eql(u8, key, "setComponent")) { + if (std.mem.eql(u8, key, "set_component")) { c.lua_pushcclosure(L, eventSetComponentThunk, 0); return 1; } @@ -579,13 +579,13 @@ fn pushPayloadField(L: *c.lua_State, ev: *Event, key: []const u8) void { c.lua_pushnil(L); } -/// `event:getComponent()` -> the current component as a native-passthrough +/// `event:get_component()` -> the current component as a native-passthrough /// userdata (or nil). The userdata wraps the `Component` (vtable+ptr) so -/// Lua can pass it straight back to `setComponent` unchanged (§7.5 wrap +/// Lua can pass it straight back to `set_component` unchanged (§7.5 wrap /// pattern: the inner is opaque to Lua but re-settable / wrappable). fn eventGetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; - // arg 1 is the event userdata (method call `event:getComponent()`). + // arg 1 is the event userdata (method call `event:get_component()`). const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0)); const bridge = ud.*; const ev = bridge.active_event orelse { @@ -600,8 +600,8 @@ fn eventGetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { return 1; } -/// `event:setComponent(c)` where `c` is EITHER a native-passthrough -/// userdata (from `getComponent`) OR a Lua component table. A table is +/// `event:set_component(c)` where `c` is EITHER a native-passthrough +/// userdata (from `get_component`) OR a Lua component table. A table is /// bridged into a native `Component` (§7.6); a userdata passes the native /// component straight through. fn eventSetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { @@ -618,20 +618,20 @@ fn eventSetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { ev.setComponent(cud.*); return 0; } - return c.luaL_error(L, "setComponent: unknown userdata (expected a component)"); + return c.luaL_error(L, "set_component: unknown userdata (expected a component)"); } if (ty == lua_bridge.T_TABLE) { const comp = bridge.makeBridgedComponent(2) catch { - return c.luaL_error(L, "setComponent: failed to bridge Lua component"); + return c.luaL_error(L, "set_component: failed to bridge Lua component"); }; ev.setComponent(comp); return 0; } - return c.luaL_error(L, "setComponent: argument must be a component (table or native handle)"); + return c.luaL_error(L, "set_component: argument must be a component (table or native handle)"); } /// Push a native `Component` as a passthrough userdata with the -/// native-component metatable (so `setComponent` can recover it). +/// native-component metatable (so `set_component` can recover it). fn pushNativeComponent(L: *c.lua_State, comp: Component) void { const ud: *Component = @ptrCast(@alignCast(c.lua_newuserdatauv(L, @sizeOf(Component), 0).?)); ud.* = comp; @@ -781,7 +781,7 @@ test "Lua-defined component renders lines through the bridged vtable" { // A handler that sets a Lua component whose render returns two lines. try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ + \\ e:set_component({ \\ render = function(self, width) return { "hello", "world" } end, \\ }) \\end) @@ -820,7 +820,7 @@ test "bridged component render truncates to the width contract" { try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ + \\ e:set_component({ \\ render = function(self, width) return { "abcdefghij" } end, \\ }) \\end) @@ -849,7 +849,7 @@ test "bridged component render error yields a safe fallback line, not a crash" { try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ + \\ e:set_component({ \\ render = function(self, width) error("boom") end, \\ }) \\end) @@ -903,12 +903,12 @@ test "wrap pattern: Lua reads the native default, wraps it, and renders through var nd = NativeDefault{ .cache = RenderCache.init(testing.allocator) }; defer nd.cache.deinit(); - // Handler: read the native default via getComponent, store it, set a Lua + // Handler: read the native default via get_component, store it, set a Lua // component that renders "[" .. inner_first_line .. "]". try runScript(L, \\panto.ext.on("tool", function(e) - \\ local inner = e:getComponent() -- native passthrough handle - \\ e:setComponent({ + \\ local inner = e:get_component() -- native passthrough handle + \\ e:set_component({ \\ inner = inner, \\ render = function(self, width) \\ -- We cannot call the native inner's render from Lua (it has no @@ -933,7 +933,7 @@ test "wrap pattern: Lua reads the native default, wraps it, and renders through test "skills-style claim-by-name (§7.5) works end-to-end through the Lua bridge" { // The canonical extension pattern: a Lua handler subscribes to `tool`, // returns early UNLESS event.tool_name matches its tool, and otherwise - // setComponent's a Lua-defined component. We fire two tool events through + // set_component's a Lua-defined component. We fire two tool events through // the NATIVE bus and assert only the matching name is claimed (its Lua // component renders), while a non-matching name keeps the native default. const L = c.luaL_newstate() orelse return error.LuaInitFailed; @@ -955,7 +955,7 @@ test "skills-style claim-by-name (§7.5) works end-to-end through the Lua bridge \\panto.ext.on("tool", function(e) \\ if e.tool_name ~= "skill" then return end -- claim-by-name \\ local name = e.tool_name -- snapshot at handler time - \\ e:setComponent({ + \\ e:set_component({ \\ render = function(self, width) return { "SKILL:" .. name } end, \\ }) \\end) @@ -1011,7 +1011,7 @@ test "skills-style claim-by-name (§7.5) works end-to-end through the Lua bridge } } -test "native-passthrough get/set round-trip: setComponent(getComponent()) keeps the native default" { +test "native-passthrough get/set round-trip: set_component(get_component()) keeps the native default" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); @@ -1049,7 +1049,7 @@ test "native-passthrough get/set round-trip: setComponent(getComponent()) keeps // Handler passes the native default straight back through. try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent(e:getComponent()) + \\ e:set_component(e:get_component()) \\end) ); try harvestOnInto(&bridge); @@ -1112,7 +1112,7 @@ test "bridged render: a long error on a NARROW width still satisfies the width c try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ + \\ e:set_component({ \\ render = function(self, width) error("a very long error message that definitely exceeds a narrow terminal width") end, \\ }) \\end) @@ -1153,7 +1153,7 @@ test "bridged render: non-array / nil / non-string returns each yield a safe fal \\idx = 0 \\panto.ext.on("tool", function(e) \\ idx = idx + 1 - \\ e:setComponent({ render = components[idx] }) + \\ e:set_component({ render = components[idx] }) \\end) ); try harvestOnInto(&bridge); @@ -1183,7 +1183,7 @@ test "bridged render: empty array renders zero lines (no fallback)" { try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, width) return {} end }) + \\ e:set_component({ render = function(self, width) return {} end }) \\end) ); try harvestOnInto(&bridge); @@ -1213,7 +1213,7 @@ test "bridged render: UTF-8 line truncates on codepoint boundaries to the width" // exactly 3 codepoints (6 bytes), not split a 2-byte sequence. try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, width) return { "\195\169\195\169\195\169\195\169\195\169" } end }) + \\ e:set_component({ render = function(self, width) return { "\195\169\195\169\195\169\195\169\195\169" } end }) \\end) ); try harvestOnInto(&bridge); @@ -1247,7 +1247,7 @@ test "bridged firstLineChanged is cache-derived: append stays near the tail" { try runScript(L, \\n = 2 \\panto.ext.on("tool", function(e) - \\ e:setComponent({ + \\ e:set_component({ \\ render = function(self, width) \\ local t = {} \\ for i = 1, n do t[i] = "line" .. i end @@ -1296,7 +1296,7 @@ test "bridged firstLineChanged: a mid-line replace reports the changed line, shr try runScript(L, \\lines = { "a", "b", "c" } \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, width) return lines end }) + \\ e:set_component({ render = function(self, width) return lines end }) \\end) ); try harvestOnInto(&bridge); @@ -1343,7 +1343,7 @@ test "bridged handleInput round-trips: a Lua method mutates state the next rende try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ + \\ e:set_component({ \\ buf = "", \\ handleInput = function(self, data) self.buf = self.buf .. data end, \\ render = function(self, width) return { self.buf } end, @@ -1390,7 +1390,7 @@ test "bridged component: invalidate frees the ref+cache eagerly; teardown is lea try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, width) return { "x", "y" } end }) + \\ e:set_component({ render = function(self, width) return { "x", "y" } end }) \\end) ); try harvestOnInto(&bridge); @@ -1537,12 +1537,12 @@ test "claim-by-name at tool_details swaps over the start default; releasing the try runScript(L, \\-- At block_start the name is unknown; set a placeholder Lua component. \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, w) return { "tool (?)" } end }) + \\ e:set_component({ render = function(self, w) return { "tool (?)" } end }) \\end) \\-- At tool_details, claim by name and swap in the real component. \\panto.ext.on("tool_details", function(e) \\ if e.tool_name ~= "skill" then return end - \\ e:setComponent({ render = function(self, w) return { "SKILL" } end }) + \\ e:set_component({ render = function(self, w) return { "SKILL" } end }) \\end) ); try harvestOnInto(&bridge); @@ -1560,7 +1560,7 @@ test "claim-by-name at tool_details swaps over the start default; releasing the try testing.expectEqual(@as(usize, 1), bridge.components.items.len); // tool_details: seed the event with the CURRENT override (the placeholder), - // mirroring how the App seeds getComponent with the current component. The + // mirroring how the App seeds get_component with the current component. The // handler claims "skill" and swaps in "SKILL". const details = bus.fire("tool_details", start, .{ .tool = .{ .index = 0, .tool_name = "skill", .id = "c0" } }).?; try testing.expect(details.ptr != start.ptr); // a NEW component @@ -1614,7 +1614,7 @@ test "claim-by-name at tool_details swaps over the start default; releasing the test "intra-emit clobber: two handlers each mint a Lua component in ONE emit; both are tracked and freed at teardown (no true leak)" { // §7.3 "last-wins-blind": when two handlers for the SAME event each call - // setComponent within a single emit, the bus keeps only the last as + // set_component within a single emit, the bus keeps only the last as // `current`. The App records only that last one as the entry's override, // so the FIRST handler's freshly-minted Lua component is never handed to // `releaseOverride` (the App never sees it). It is therefore NOT released @@ -1626,8 +1626,8 @@ test "intra-emit clobber: two handlers each mint a Lua component in ONE emit; bo // - but it IS per-emit accumulation: a clobbering handler chain grows // `bridge.components` by one orphan per clobber for the runtime's life. // - // The documented mitigation is the §7.3 wrap pattern (getComponent -> - // wrap -> setComponent), under which no component is orphaned because each + // The documented mitigation is the §7.3 wrap pattern (get_component -> + // wrap -> set_component), under which no component is orphaned because each // handler decorates the current one instead of minting a rival. A handler // that clobbers blind is at fault per the plan; the resource is still // reclaimed at teardown, so correctness (no UAF / no true leak) holds. @@ -1646,10 +1646,10 @@ test "intra-emit clobber: two handlers each mint a Lua component in ONE emit; bo // component, ignoring the current one. try runScript(L, \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, w) return { "FIRST" } end }) + \\ e:set_component({ render = function(self, w) return { "FIRST" } end }) \\end) \\panto.ext.on("tool", function(e) - \\ e:setComponent({ render = function(self, w) return { "SECOND" } end }) + \\ e:set_component({ render = function(self, w) return { "SECOND" } end }) \\end) ); try harvestOnInto(&bridge); diff --git a/src/main.zig b/src/main.zig index dbaa4d8..4b26a22 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,7 @@ const std = @import("std"); const panto = @import("panto"); +const pricing_format = @import("pricing_format.zig"); +const markdown = @import("markdown.zig"); const lua_bridge = @import("lua_bridge.zig"); const lua_runtime = @import("lua_runtime.zig"); const lua_event_bridge = @import("lua_event_bridge.zig"); @@ -67,6 +69,8 @@ test { _ = command; _ = command_compaction; _ = debug_log; + _ = pricing_format; + _ = markdown; _ = tui_terminal; _ = tui_key; _ = tui_input; @@ -573,6 +577,7 @@ pub fn main(init: std.process.Init) !void { agent, &app_config, &models.defs, + &models.pricing, &active_config, model_label, ); diff --git a/src/markdown.zig b/src/markdown.zig new file mode 100644 index 0000000..a029756 --- /dev/null +++ b/src/markdown.zig @@ -0,0 +1,332 @@ +//! Markdown rendering for the TUI. +//! +//! Parsing is delegated to MD4C (a small C CommonMark parser). This module is +//! only the terminal renderer: it turns MD4C's block/span/text callbacks into +//! ANSI-styled, width-bounded lines for panto components. + +const std = @import("std"); +const theme = @import("tui_theme.zig"); + +const c = @cImport({ + @cInclude("md4c.h"); +}); + +const Allocator = std.mem.Allocator; + +/// Return the largest prefix that is safe to render while markdown is still +/// streaming. Avoid rendering an unterminated trailing line (which may still +/// become a heading/list/code fence/etc.) and avoid entering an unclosed fenced +/// code block. +pub fn streamingSafeCut(buffer: []const u8) []const u8 { + if (buffer.len == 0) return buffer[0..0]; + const last_nl = std.mem.lastIndexOfScalar(u8, buffer, '\n') orelse return buffer[0..0]; + var safe = buffer[0 .. last_nl + 1]; + + var in_fence = false; + var fence_char: u8 = 0; + var fence_len: usize = 0; + var line_start: usize = 0; + while (line_start < safe.len) { + var line_end = line_start; + while (line_end < safe.len and safe[line_end] != '\n') line_end += 1; + const line = std.mem.trimStart(u8, safe[line_start..line_end], " \t"); + if (line.len >= 3 and (line[0] == '`' or line[0] == '~')) { + var n: usize = 0; + while (n < line.len and line[n] == line[0]) n += 1; + if (n >= 3) { + if (!in_fence) { + in_fence = true; + fence_char = line[0]; + fence_len = n; + } else if (line[0] == fence_char and n >= fence_len) { + in_fence = false; + } + } + } + line_start = if (line_end < safe.len) line_end + 1 else safe.len; + } + if (!in_fence) return safe; + + // If a fence is open, render only through the line before its opener. + var opener: usize = 0; + line_start = 0; + while (line_start < safe.len) { + var line_end = line_start; + while (line_end < safe.len and safe[line_end] != '\n') line_end += 1; + const line = std.mem.trimStart(u8, safe[line_start..line_end], " \t"); + if (line.len >= 3 and line[0] == fence_char) { + var n: usize = 0; + while (n < line.len and line[n] == fence_char) n += 1; + if (n >= fence_len) opener = line_start; + } + line_start = if (line_end < safe.len) line_end + 1 else safe.len; + } + return safe[0..opener]; +} + +fn isAnsiAt(s: []const u8, i: usize) bool { + return i + 1 < s.len and s[i] == '\x1b' and s[i + 1] == '['; +} + +fn skipAnsi(s: []const u8, start_i: usize) usize { + var i = start_i + 2; + while (i < s.len) : (i += 1) { + const ch = s[i]; + if (ch >= '@' and ch <= '~') return i + 1; + } + return s.len; +} + +fn visibleWidth(s: []const u8) usize { + var w: usize = 0; + var i: usize = 0; + while (i < s.len) { + if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; } + const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; + i += @min(n, s.len - i); + w += 1; + } + return w; +} + +fn takeVisible(s: []const u8, max: usize) usize { + var w: usize = 0; + var i: usize = 0; + while (i < s.len) { + if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; } + const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; + const adv = @min(n, s.len - i); + if (w + 1 > max) break; + i += adv; + w += 1; + } + return i; +} + +/// ANSI-aware greedy wrapping. The input may contain CSI styling escapes. +pub fn wrapStyled(buf: []const u8, width: usize, out: *std.ArrayList(u8), alloc: Allocator) !void { + const w = @max(width, 1); + var line_start: usize = 0; + while (line_start <= buf.len) { + const nl = std.mem.indexOfScalarPos(u8, buf, line_start, '\n') orelse buf.len; + var rest = buf[line_start..nl]; + while (visibleWidth(rest) > w) { + var cut = takeVisible(rest, w); + if (cut < rest.len) { + if (std.mem.lastIndexOfScalar(u8, rest[0..cut], ' ')) |sp| { + if (sp > 0) cut = sp; + } + } + try out.appendSlice(alloc, rest[0..cut]); + try out.append(alloc, '\n'); + rest = std.mem.trimStart(u8, rest[cut..], " "); + } + try out.appendSlice(alloc, rest); + if (nl == buf.len) break; + try out.append(alloc, '\n'); + line_start = nl + 1; + } +} + +pub const Renderer = struct { + alloc: Allocator, + width: usize, + out_lines: *std.ArrayList([]const u8), + + buf: std.ArrayList(u8) = .empty, + in_code_block: bool = false, + list_depth: usize = 0, + heading_level: usize = 0, + err: ?anyerror = null, + + pub fn render(self: *Renderer, src: []const u8) !void { + self.buf = .empty; + defer self.buf.deinit(self.alloc); + self.err = null; + + var parser: c.MD_PARSER = std.mem.zeroes(c.MD_PARSER); + parser.abi_version = 0; + parser.flags = c.MD_FLAG_TABLES | c.MD_FLAG_STRIKETHROUGH | c.MD_FLAG_PERMISSIVEURLAUTOLINKS; + parser.enter_block = enterBlock; + parser.leave_block = leaveBlock; + parser.enter_span = enterSpan; + parser.leave_span = leaveSpan; + parser.text = textCb; + + const rc = c.md_parse(src.ptr, @intCast(src.len), &parser, self); + if (self.err) |e| return e; + if (rc != 0) return error.MarkdownParseFailed; + try self.flushParagraph(); + while (self.out_lines.items.len > 0 and self.out_lines.items[self.out_lines.items.len - 1].len == 0) { + const last = self.out_lines.pop().?; + self.alloc.free(last); + } + } + + fn add(self: *Renderer, s: []const u8) !void { try self.buf.appendSlice(self.alloc, s); } + + fn appendLine(self: *Renderer, s: []const u8) !void { + const line = try self.alloc.dupe(u8, s); + errdefer self.alloc.free(line); + try self.out_lines.append(self.alloc, line); + } + + fn appendBlank(self: *Renderer) !void { + if (self.out_lines.items.len == 0) return; + if (self.out_lines.items[self.out_lines.items.len - 1].len == 0) return; + try self.appendLine(""); + } + + fn flushParagraph(self: *Renderer) !void { + const text = std.mem.trim(u8, self.buf.items, " \t\n"); + if (text.len == 0) { self.buf.clearRetainingCapacity(); return; } + var wrapped: std.ArrayList(u8) = .empty; + defer wrapped.deinit(self.alloc); + try wrapStyled(text, self.width, &wrapped, self.alloc); + var it = std.mem.splitScalar(u8, wrapped.items, '\n'); + while (it.next()) |line| try self.appendLine(line); + self.buf.clearRetainingCapacity(); + } + + fn flushCodeBlock(self: *Renderer) !void { + const code = theme.default.fg(.tool_header); + var it = std.mem.splitScalar(u8, self.buf.items, '\n'); + while (it.next()) |line| { + if (line.len == 0) continue; + var tmp: std.ArrayList(u8) = .empty; + defer tmp.deinit(self.alloc); + try tmp.appendSlice(self.alloc, code.open()); + try tmp.appendSlice(self.alloc, " "); + try tmp.appendSlice(self.alloc, line); + try tmp.appendSlice(self.alloc, code.close()); + try self.appendLine(tmp.items); + } + self.buf.clearRetainingCapacity(); + } + + fn fail(self: *Renderer, e: anyerror) c_int { self.err = e; return 1; } + + fn enterBlock(t: c.MD_BLOCKTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { + const self: *Renderer = @ptrCast(@alignCast(userdata.?)); + switch (t) { + c.MD_BLOCK_H => { + self.flushParagraph() catch |e| return self.fail(e); + const d: *c.MD_BLOCK_H_DETAIL = @ptrCast(@alignCast(detail.?)); + self.heading_level = @intCast(d.level); + self.add(theme.default.fg(.welcome).open()) catch |e| return self.fail(e); + var i: usize = 0; while (i < self.heading_level) : (i += 1) self.add("#") catch |e| return self.fail(e); + self.add(" ") catch |e| return self.fail(e); + }, + c.MD_BLOCK_P => {}, + c.MD_BLOCK_CODE => { self.in_code_block = true; self.buf.clearRetainingCapacity(); }, + c.MD_BLOCK_UL, c.MD_BLOCK_OL => self.list_depth += 1, + c.MD_BLOCK_LI => { + self.flushParagraph() catch |e| return self.fail(e); + self.add("• ") catch |e| return self.fail(e); + }, + c.MD_BLOCK_HR => self.appendLine("────────") catch |e| return self.fail(e), + else => {}, + } + return 0; + } + + fn leaveBlock(t: c.MD_BLOCKTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { + _ = detail; + const self: *Renderer = @ptrCast(@alignCast(userdata.?)); + switch (t) { + c.MD_BLOCK_H => { + self.add(theme.default.fg(.welcome).close()) catch |e| return self.fail(e); + self.flushParagraph() catch |e| return self.fail(e); + self.appendBlank() catch |e| return self.fail(e); + self.heading_level = 0; + }, + c.MD_BLOCK_P, c.MD_BLOCK_LI => { + self.flushParagraph() catch |e| return self.fail(e); + }, + c.MD_BLOCK_CODE => { + self.flushCodeBlock() catch |e| return self.fail(e); + self.appendBlank() catch |e| return self.fail(e); + self.in_code_block = false; + }, + c.MD_BLOCK_UL, c.MD_BLOCK_OL => { + if (self.list_depth > 0) self.list_depth -= 1; + }, + else => {}, + } + return 0; + } + + fn enterSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { + _ = detail; + const self: *Renderer = @ptrCast(@alignCast(userdata.?)); + const s = switch (t) { + c.MD_SPAN_STRONG => "\x1b[1m", + c.MD_SPAN_EM => "\x1b[3m", + c.MD_SPAN_CODE => theme.default.fg(.tool_header).open(), + c.MD_SPAN_A => "\x1b[4m", + c.MD_SPAN_DEL => "\x1b[9m", + else => "", + }; + self.add(s) catch |e| return self.fail(e); + return 0; + } + + fn leaveSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { + _ = t; _ = detail; + const self: *Renderer = @ptrCast(@alignCast(userdata.?)); + self.add(theme.reset) catch |e| return self.fail(e); + return 0; + } + + fn textCb(t: c.MD_TEXTTYPE, p: [*c]const u8, size: c.MD_SIZE, userdata: ?*anyopaque) callconv(.c) c_int { + const self: *Renderer = @ptrCast(@alignCast(userdata.?)); + const s = p[0..@intCast(size)]; + switch (t) { + c.MD_TEXT_BR, c.MD_TEXT_SOFTBR => self.add(" ") catch |e| return self.fail(e), + c.MD_TEXT_NULLCHAR => self.add("�") catch |e| return self.fail(e), + c.MD_TEXT_ENTITY => self.add(decodeEntity(s)) catch |e| return self.fail(e), + else => self.add(s) catch |e| return self.fail(e), + } + return 0; + } +}; + +fn decodeEntity(s: []const u8) []const u8 { + if (std.mem.eql(u8, s, "&")) return "&"; + if (std.mem.eql(u8, s, "<")) return "<"; + if (std.mem.eql(u8, s, ">")) return ">"; + if (std.mem.eql(u8, s, """)) return "\""; + if (std.mem.eql(u8, s, "'")) return "'"; + return s; +} + +const testing = std.testing; + +test "streamingSafeCut waits for newline" { + try testing.expectEqualStrings("", streamingSafeCut("hello")); + try testing.expectEqualStrings("foo\n", streamingSafeCut("foo\nbar")); +} + +test "wrapStyled wraps plain text" { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); + try wrapStyled("the quick brown fox", 10, &out, testing.allocator); + try testing.expectEqualStrings("the quick\nbrown fox", out.items); +} + +test "Renderer uses MD4C for common markdown" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("# Heading\n\ncode `x` and **bold** and *italic* and [a](u).\n\n```zig\nfn main() {}\n```\n"); + try testing.expect(out.items.len > 3); + try testing.expect(std.mem.indexOf(u8, out.items[0], "Heading") != null); + var found_code = false; + for (out.items) |l| { + if (std.mem.indexOf(u8, l, "fn main") != null) found_code = true; + } + try testing.expect(found_code); +} diff --git a/src/pricing_format.zig b/src/pricing_format.zig new file mode 100644 index 0000000..daceff9 --- /dev/null +++ b/src/pricing_format.zig @@ -0,0 +1,214 @@ +//! Display formatters for `panto.Pricing` and accumulated session cost. +//! +//! libpanto stores prices as micro-cents per token; the human-friendly +//! unit is USD per million tokens (e.g. "3.0"). The formatters here +//! convert the integer representation to the display form, with two +//! shapes: +//! +//! - `formatPriceCompact` — the dense `"1i/5o/0.1r/1.25w"` form +//! used in the model picker, where the four rates are the only +//! content and the user reads them in a single glance. +//! - `formatCostDollars` — the canonical dollar amount +//! (`"$0.06"`, `"$1.23"`), used in the footer session total. +//! +//! The two have to make a few choices consistently: +//! +//! - Trailing zeros are dropped: "$3.00/Mtok" prints as `"3"`, not +//! `"3.00"`, and the compact form does the same. +//! - Sub-cent values round to the nearest cent, not to 0 or to a +//! half-cent (so `$0.005` becomes `$0.01`, matching what a bank +//! would say). +//! +//! The compact form omits any category whose price is `null` (unknown). +//! When ALL categories are unknown, the result is `null` and the +//! caller renders the bare model id (no price tag). + +const std = @import("std"); +const panto = @import("panto"); + +const Pricing = panto.Pricing; + +/// Suffixes for the compact form, in field order: input, output, +/// cache read, cache write. Known zeros still print (the +/// `cache_write = 0` convention for OpenAI), so the slot's presence +/// is determined by the field, not by its value. +const SUFFIXES = [_]u8{ 'i', 'o', 'r', 'w' }; + +/// The user-facing compact form for a single (provider, model) rate +/// set, e.g. "1i/5o/0.1r/1.25w". Returns `null` when EVERY field is +/// `null` (no pricing known) so the caller can fall back to a +/// model-only label without showing a dangling `?/?/?/?`. +/// +/// Allocation: caller-owned, written into `buf`. The 64-byte buffer +/// is more than enough — the worst case is four fields of up to +/// eight digits plus three slashes plus four suffix bytes. +pub fn formatPriceCompact(pricing: Pricing, buf: []u8) ?[]const u8 { + var out_len: usize = 0; + var emitted: usize = 0; + + const fields = [_]?u64{ pricing.input, pricing.output, pricing.cache_read, pricing.cache_write }; + var i: usize = 0; + while (i < 4) : (i += 1) { + const v = fields[i] orelse continue; + if (emitted != 0) { + if (out_len >= buf.len) return null; + buf[out_len] = '/'; + out_len += 1; + } + var scratch: [16]u8 = undefined; + const text = formatRate(v, &scratch); + if (out_len + text.len + 1 > buf.len) return null; + @memcpy(buf[out_len .. out_len + text.len], text); + out_len += text.len; + buf[out_len] = SUFFIXES[i]; + out_len += 1; + emitted += 1; + } + if (emitted == 0) return null; + return buf[0..out_len]; +} + +/// Format a single micro-cents-per-token value as a human price per +/// million tokens, e.g. 300 -> "3", 30 -> "0.3", 375 -> "3.75", +/// 5 -> "0.05". Strips trailing zeros (and a trailing decimal +/// point) so the result is the shortest accurate representation. +fn formatRate(micro_cents_per_token: u64, buf: []u8) []const u8 { + // We work with the integer directly: `value / 100` is whole + // dollars; `value % 100` is the cents fraction. Render the + // cents, then drop trailing zeros (and the dot if all zeros). + const whole = micro_cents_per_token / 100; + const frac = micro_cents_per_token % 100; + + // 16-byte buffer is more than enough for "99999999.99" (11 chars). + var w: usize = 0; + const whole_text = std.fmt.bufPrint(buf[w..], "{d}", .{whole}) catch return buf[0..0]; + w += whole_text.len; + if (frac != 0) { + buf[w] = '.'; + w += 1; + // Two-digit cents with a leading zero, e.g. 5 -> "05". + const cents_text = std.fmt.bufPrint(buf[w..], "{d:0>2}", .{frac}) catch return buf[0..0]; + w += cents_text.len; + // Drop trailing zeros: "0.30" -> "0.3", "0.05" stays. + while (w > 0 and buf[w - 1] == '0') w -= 1; + // If we dropped the last zero, also drop the dot. + if (w > 0 and buf[w - 1] == '.') w -= 1; + } + return buf[0..w]; +} + +/// Format an accumulated micro-cents total as a dollar amount +/// `"$X.YY"`. Sub-cent values round to the nearest cent; the +/// (rare) case where cents rounding overflows 100 folds to the +/// next dollar. +pub fn formatCostDollars(micro_cents_total: ?u64, buf: []u8) []const u8 { + const total = micro_cents_total orelse return buf[0..0]; + // 1 dollar = 100 cents = 100_000_000 micro-cents. Divide into + // whole dollars and a cents residue, then round the cents to + // the nearest cent. Pure integer arithmetic — no float drift. + const total_cents = total / 1_000_000; // 0.01 USD = 1 cent + const rounding = (total % 1_000_000) / 500_000; // 0 -> no round, 1 -> round up + const cents_total = total_cents + rounding; + const dollars = cents_total / 100; + const cents = cents_total % 100; + return std.fmt.bufPrint(buf, "${d}.{d:0>2}", .{ dollars, cents }) catch buf[0..0]; +} + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +test "formatPriceCompact: shows all four rates with minimal digits" { + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 100, .output = 500, .cache_read = 10, .cache_write = 125 }; + const got = formatPriceCompact(p, &buf).?; + // 100 micro-cents/token = $1.00/Mtok -> "1i" + // 500 -> $5.00 -> "5o" + // 10 -> $0.10 -> "0.1r" + // 125 -> $1.25 -> "1.25w" + try testing.expectEqualStrings("1i/5o/0.1r/1.25w", got); +} + +test "formatPriceCompact: omits unknown fields" { + var buf: [64]u8 = undefined; + const partial: Pricing = .{ .input = 300, .output = 1500 }; + const got = formatPriceCompact(partial, &buf).?; + try testing.expectEqualStrings("3i/15o", got); +} + +test "formatPriceCompact: all-unknown -> null" { + var buf: [64]u8 = undefined; + try testing.expect(formatPriceCompact(.{ .input = null, .output = null, .cache_read = null, .cache_write = null }, &buf) == null); +} + +test "formatPriceCompact: explicit zero prints as 0" { + // OpenAI's cache_write is a known zero, not unknown — must surface as + // "0w" rather than being silently dropped. + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 250, .output = 1000, .cache_read = 125, .cache_write = 0 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("2.5i/10o/1.25r/0w", got); +} + +test "formatPriceCompact: integer rates strip trailing .00" { + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 300, .output = 1500 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("3i/15o", got); + // No spurious ".00". + try testing.expect(std.mem.indexOf(u8, got, ".00") == null); +} + +test "formatPriceCompact: sub-cent rate keeps leading zero" { + // 0.05 -> "0.05" (not ".05" or "5e-2"). + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 5 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("0.05i", got); +} + +test "formatPriceCompact: sub-cent with trailing zero drops it" { + // 0.10 -> "0.1" (the trailing zero is dropped, but the leading 0 stays). + var buf: [64]u8 = undefined; + const p: Pricing = .{ .cache_read = 10 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("0.1r", got); +} + +test "formatPriceCompact: example given in the spec (haiku 4.5)" { + // 1i/5o/0.1r/1.25w per the user's example. + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 100, .output = 500, .cache_read = 10, .cache_write = 125 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("1i/5o/0.1r/1.25w", got); +} + +test "formatCostDollars: zero, sub-cent, whole-dollar, mixed" { + var buf: [32]u8 = undefined; + try testing.expectEqualStrings("$0.00", formatCostDollars(0, &buf)); + // 100_000 micro-cents = $0.001 -> rounded to nearest cent = $0.00. + // (The footer uses "X.YY" form; sub-cent values round.) + try testing.expectEqualStrings("$0.00", formatCostDollars(100_000, &buf)); + // 600_000 micro-cents = $0.006 -> $0.01. + try testing.expectEqualStrings("$0.01", formatCostDollars(600_000, &buf)); + // 6_000_000 micro-cents = $0.06 exactly. + try testing.expectEqualStrings("$0.06", formatCostDollars(6_000_000, &buf)); + // 100_000_000 micro-cents = $1.00. + try testing.expectEqualStrings("$1.00", formatCostDollars(100_000_000, &buf)); + // 123_450_000 micro-cents = $1.2345 -> $1.23. + try testing.expectEqualStrings("$1.23", formatCostDollars(123_450_000, &buf)); +} + +test "formatCostDollars: cent-rounding overflow folds to next dollar" { + // $0.9995 rounds to $1.00; verify the overflow path that nudges the + // dollars counter. + var buf: [32]u8 = undefined; + try testing.expectEqualStrings("$1.00", formatCostDollars(99_950_000, &buf)); +} + +test "formatCostDollars: null total returns empty" { + var buf: [32]u8 = undefined; + try testing.expectEqualStrings("", formatCostDollars(null, &buf)); +} diff --git a/src/tui_app.zig b/src/tui_app.zig index f9c58fe..e44a74a 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -67,6 +67,7 @@ const selectors_mod = @import("tui_selectors.zig"); const config_file = @import("config_file.zig"); const auth_manager = @import("auth_manager.zig"); const models_toml = @import("models_toml.zig"); +const pricing_format = @import("pricing_format.zig"); const tui_key = @import("tui_key.zig"); const Terminal = terminal_mod.Terminal; @@ -301,6 +302,15 @@ pub const App = struct { /// the picker overlays. selectors: ?*SelectorController = null, + /// Optional hook the App invokes on every `message_complete` carrying + /// a `Usage`. The hook (installed by the `SelectorController`) updates + /// session-running totals keyed by the current `(provider, model)` and + /// pushes the latest values into the footer. With no hook installed + /// (e.g. tests) the App still updates the per-turn context-window + /// tokens but does NOT accumulate session totals. + usage_record_ctx: ?*anyopaque = null, + usage_record_fn: ?*const fn (ctx: *anyopaque, usage: panto.Usage) void = null, + /// Whether the input box currently participates in the engine list. It is /// removed during an in-flight turn (so streaming output appends below the /// transcript) and re-added when the turn completes. P1 keeps it simple: @@ -358,6 +368,21 @@ pub const App = struct { self.flush_fn = f; } + /// Install the per-turn usage record hook (the `SelectorController` + /// calls this). On every `message_complete` the App invokes the + /// hook with the just-reported `Usage`; the hook keys the usage by + /// the current `(provider, model)` (its own concern) and pushes the + /// new session totals back into the footer. Tests omit this; the + /// per-turn context-window tokens still get updated. + pub fn setUsageRecorder( + self: *App, + ctx: *anyopaque, + f: *const fn (ctx: *anyopaque, usage: panto.Usage) void, + ) void { + self.usage_record_ctx = ctx; + self.usage_record_fn = f; + } + fn flushSink(self: *App) void { if (self.flush_fn) |f| f(self.flush_ctx.?); } @@ -866,6 +891,10 @@ pub const App = struct { .block_complete => |b| { switch (b.block) { .Text => { + if (self.router.get(b.index)) |ref| switch (ref) { + .assistant => |box| box.finishStream(), + else => {}, + }; try self.fireTextLifecycle(b.index, .assistant, .assistant_text_complete, "assistant_text_complete", .{ .assistant_text = .{ .index = b.index, .text = if (self.router.get(b.index)) |r| (if (r == .assistant) r.assistant.buffer.items else "") else "", @@ -915,8 +944,13 @@ pub const App = struct { // (output/reasoning excluded — not "in the window"). Latest // value wins; not accumulated. if (mc.usage) |u| { - const ctx = u.input + u.cache_read + u.cache_write + u.output; + const ctx = u.input + u.cache_read + u.cache_write; self.footer.setContextTokens(ctx); + // Hand the raw usage to the recorder (the + // `SelectorController` is the canonical recorder; it + // knows which `(provider, model)` produced this turn + // and accumulates per-model token + cost totals). + if (self.usage_record_fn) |f| f(self.usage_record_ctx.?, u); self.scheduler.requestRender(); } }, @@ -939,6 +973,11 @@ pub const App = struct { } self.scheduler.requestRender(); }, + .tool_dispatch_result => |info| { + // Eager per-tool result carrier. Correlate by tool_use_id just + // like the aggregate completion event. + try self.routeToolResults(info.message); + }, .tool_dispatch_complete => |info| { // ToolResult blocks are delivered together here as the content // of the appended user message. Correlate each back to its @@ -1019,6 +1058,20 @@ fn modelPickerRowLessThan(_: void, a: ModelPickerRow, b: ModelPickerRow) bool { return std.mem.lessThan(u8, a.item.detail, b.item.detail); } +fn addTokenBucket( + alloc: std.mem.Allocator, + buckets: *std.StringHashMapUnmanaged(u64), + key: []const u8, + amount: u64, +) void { + const gop = buckets.getOrPut(alloc, key) catch return; + if (!gop.found_existing) { + gop.key_ptr.* = alloc.dupe(u8, key) catch return; + gop.value_ptr.* = 0; + } + gop.value_ptr.* +%= amount; +} + pub const SelectorController = struct { alloc: std.mem.Allocator, app: *App, @@ -1027,6 +1080,11 @@ pub const SelectorController = struct { /// transport/auth and per-alias knobs lookups via `buildProviderConfig`. file_cfg: *const config_file.Config, defs: *const models_toml.ModelRegistry, + /// Per-(provider, wire-model) pricing table for the session. Borrowed + /// (the merged `models_toml.Models` outlives the controller). The + /// model-picker detail line and the footer session cost both look up + /// here; the controller does NOT mutate it. + pricing: *const panto.PricingRegistry, /// The live agent config snapshot, owned here. `agent` holds a pointer to /// it; we mutate `provider` in place and re-`setConfig` so the change is /// observed at the next turn. @@ -1056,12 +1114,35 @@ pub const SelectorController = struct { /// The current model label ("provider:alias"), for the footer/preselect. model_label: []u8, + /// Session-running token totals, keyed by the `(provider, model)` + /// pair that produced them. A model switch in the middle of a + /// session just appends a new bucket; the SUM is what the footer + /// displays. Each entry is owned here and is the integer sum of + /// `input + output + cache_read + cache_write` for the turns + /// produced by that model. + /// + /// WHY PER-MODEL: the user can switch models mid-session; a + /// flat u64 would conflate two models' tokens. Per-model lets us + /// re-pricing: if the user pastes a corrected `models.toml` mid- + /// session we just rebuild the cost (the next addCost call picks + /// up the new pricing), and the token sum is the *same* flat + /// total regardless of pricing changes. + session_token_buckets: std.StringHashMapUnmanaged(u64) = .empty, + + /// Session-running cost total in micro-cents. `null` means "at + /// least one priced component of at least one turn was unknown" + /// (the `addCost` poison rule). Set to 0 for the first + /// ALL-ZERO turn and stays known thereafter; any later + /// `costMicroCents == null` re-poisons. + session_cost: ?u64 = 0, + pub fn init( alloc: std.mem.Allocator, app: *App, agent: *panto.Agent, file_cfg: *const config_file.Config, defs: *const models_toml.ModelRegistry, + pricing: *const panto.PricingRegistry, live: *panto.Config, initial_label: []const u8, ) !*SelectorController { @@ -1073,12 +1154,17 @@ pub const SelectorController = struct { .agent = agent, .file_cfg = file_cfg, .defs = defs, + .pricing = pricing, .live = live, .model_items = &.{}, .model_label = try alloc.dupe(u8, initial_label), }; try self.buildModelItems(); try self.refreshFooter(); + // Install ourselves as the App's per-turn usage recorder so every + // `message_complete` lands in our session-running buckets and the + // footer session cost/token total is updated. + app.setUsageRecorder(self, recordUsageThunk); return self; } @@ -1105,6 +1191,10 @@ pub const SelectorController = struct { self.alloc.free(self.model_entry_indices); self.alloc.free(self.reasoning_items); self.alloc.free(self.model_label); + // The session token buckets own the (provider, model) key strings. + var it = self.session_token_buckets.iterator(); + while (it.next()) |entry| self.alloc.free(entry.key_ptr.*); + self.session_token_buckets.deinit(self.alloc); self.alloc.destroy(self); } @@ -1116,7 +1206,7 @@ pub const SelectorController = struct { for (self.defs.entries.items, 0..) |d, def_index| { const label = try std.fmt.allocPrint(a, "{s}:{s}", .{ d.provider, d.alias }); try self.model_strings.append(a, label); - const detail = try formatModelDetail(a, d); + const detail = try formatModelDetail(a, d, self.pricing); try self.model_strings.append(a, detail); try rows.append(a, .{ .def_index = def_index, .item = .{ .label = label, .detail = detail } }); } @@ -1246,14 +1336,91 @@ pub const SelectorController = struct { defer self.alloc.free(msg); _ = self.app.spawnStatus(msg) catch {}; } + + /// Record one turn's `usage` against the current `(provider, model)`, + /// then push the new session totals into the footer. Model-switch + /// tolerance: each `(provider, model)` is a separate bucket for + /// the per-model token sum; the FOOTER displays the flat sum + /// across all buckets, so a switch in the middle of a session is + /// invisible to the user (it just makes the next turn's tokens + /// land in a new bucket). For cost, the same `addCost` accumulator + /// is used; the per-turn cost is computed against THIS turn's + /// `(provider, model)` pricing, so a model with known pricing + /// before an unpriced model after still produces a known total + /// only up to the switch. + fn recordUsage(self: *SelectorController, usage: panto.Usage) void { + // Resolve the (provider, wire-model) pair for THIS turn. The + // pricing registry is keyed on these strings; the bucket + // string is "<provider>:<wire>" so a model name that happens + // to be reused across providers doesn't collide. + const colon = std.mem.indexOfScalar(u8, self.model_label, ':') orelse { + // A malformed label (no colon) is a programmer error in + // the boot sequence; just bail. The App still shows the + // per-turn context-window tokens. + return; + }; + const provider_name = self.model_label[0..colon]; + const wire_model = selectors_mod.wireModel(self.live.provider); + + const turn_tokens = usage.input + usage.output + usage.cache_read + usage.cache_write; + const key = std.fmt.allocPrint(self.alloc, "{s}:{s}", .{ provider_name, wire_model }) catch return; + defer self.alloc.free(key); + addTokenBucket(self.alloc, &self.session_token_buckets, key, turn_tokens); + + // Recompute the flat session sum. Cheap: the bucket count is + // tiny (the user doesn't switch models a thousand times). + var total: u64 = 0; + var it = self.session_token_buckets.iterator(); + while (it.next()) |entry| total +%= entry.value_ptr.*; + self.app.footer.setSessionTokens(total); + + // Cost: look up the pricing for the model that JUST produced + // this usage. If any priced component is null (no models.toml + // entry for this model), this turn's cost is null and + // `addCost` poisons the session total to null — once poisoned, + // it stays null for the rest of the session (no way to + // "un-poison" a `?u64` back to a known value). + // + // An empty (default-constructed) Pricing has every field null; + // `costMicroCents` treats every nonzero token usage as + // "unknown" and returns null. That's the same as the + // no-pricing case, so a single code path covers both. + const turn_cost: ?u64 = if (self.pricing.get(provider_name, wire_model)) |pricing| + panto.costMicroCents(usage, pricing) + else + null; + self.session_cost = panto.addCost(self.session_cost, turn_cost); + self.app.footer.setSessionCost(self.session_cost); + } + + /// Static thunk for the App's `setUsageRecorder` callback. The + /// `ctx` we install with is the `*SelectorController` itself; the + /// thunk casts it back and dispatches to the typed method. + fn recordUsageThunk(ctx: *anyopaque, usage: panto.Usage) void { + const self: *SelectorController = @ptrCast(@alignCast(ctx)); + self.recordUsage(usage); + } }; -/// Format the dim detail string for a model item: the wire model id plus the -/// reasoning/thinking knobs declared in `models.toml`. -fn formatModelDetail(alloc: std.mem.Allocator, d: models_toml.ModelDef) ![]u8 { - // Anthropic-style entries advertise thinking/effort; openai-style ones - // advertise reasoning. We don't know the provider's API style here, so we - // show whatever knobs are non-default. + +/// Format the dim detail string for a model item: the wire model id, +/// the reasoning/thinking knobs declared in `models.toml`, and the +/// pricing (when known). The pricing is looked up by the wire model +/// id against the parsed `PricingRegistry` (the same keying the +/// session cost accumulator uses); it surfaces as a compact +/// `"1i/5o/0.1r/1.25w"` suffix on the same line so the picker row +/// is a one-glance summary. +/// +/// Anthropic-style entries advertise thinking/effort; openai-style +/// ones advertise reasoning. We don't know the provider's API style +/// here, so we show whatever knobs are non-default. Pricing is +/// appended last so the most-novel information lands closest to the +/// model label and the older knob info reads as supporting context. +fn formatModelDetail( + alloc: std.mem.Allocator, + d: models_toml.ModelDef, + pricing: *const panto.PricingRegistry, +) ![]u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(alloc); try buf.appendSlice(alloc, d.model); @@ -1268,6 +1435,13 @@ fn formatModelDetail(alloc: std.mem.Allocator, d: models_toml.ModelDef) ![]u8 { try buf.appendSlice(alloc, " reasoning:"); try buf.appendSlice(alloc, @tagName(d.reasoning)); } + if (pricing.get(d.provider, d.model)) |p| { + var scratch: [64]u8 = undefined; + if (pricing_format.formatPriceCompact(p, &scratch)) |tag| { + try buf.appendSlice(alloc, " "); + try buf.appendSlice(alloc, tag); + } + } return buf.toOwnedSlice(alloc); } @@ -2030,13 +2204,15 @@ test "routeEvent: full event stream renders through the real engine, no stdout" h.app.beginTurn(); try h.app.routeEvent(.{ .message_start = .assistant }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); + // The streaming path renders complete lines through markdown and shows the + // trailing partial line verbatim as it arrives. try h.app.routeEvent(delta(0, "Hi there")); - try h.app.routeEvent(.{ .turn_complete = {} }); - try h.app.renderNow(); - const out = h.buf.written(); - // The assistant text reached the engine output (not stdout). - try testing.expect(std.mem.indexOf(u8, out, "Hi there") != null); + try testing.expect(std.mem.indexOf(u8, h.buf.written(), "Hi there") != null); + try h.app.routeEvent(delta(0, "\n")); + try h.app.renderNow(); + try testing.expect(std.mem.indexOf(u8, h.buf.written(), "Hi there") != null); + try h.app.routeEvent(.{ .turn_complete = {} }); } test "beginTurn clears the block-index map but keeps transcript history" { @@ -2306,12 +2482,17 @@ fn testModelDef(provider: []const u8, alias: []const u8, model: []const u8) mode }; } -test "formatModelDetail: shows wire model + non-default knobs" { +test "formatModelDetail: shows wire model + non-default knobs + pricing" { const alloc = testing.allocator; + // Empty pricing registry: no tag appended (this is the default shape for + // test definitions that don't bother declaring a price). + var pricing = panto.PricingRegistry.init(alloc); + defer pricing.deinit(); + // Plain entry: just the wire model id. { const d = testModelDef("openai", "gpt", "gpt-4o"); - const s = try formatModelDetail(alloc, d); + const s = try formatModelDetail(alloc, d, &pricing); defer alloc.free(s); try testing.expectEqualStrings("gpt-4o", s); } @@ -2319,7 +2500,7 @@ test "formatModelDetail: shows wire model + non-default knobs" { { var d = testModelDef("openai", "o3", "o3"); d.reasoning = .high; - const s = try formatModelDetail(alloc, d); + const s = try formatModelDetail(alloc, d, &pricing); defer alloc.free(s); try testing.expect(std.mem.indexOf(u8, s, "reasoning:high") != null); } @@ -2328,11 +2509,24 @@ test "formatModelDetail: shows wire model + non-default knobs" { var d = testModelDef("anthropic", "opus", "claude-opus-4"); d.thinking = .adaptive; d.effort = .xhigh; - const s = try formatModelDetail(alloc, d); + const s = try formatModelDetail(alloc, d, &pricing); defer alloc.free(s); try testing.expect(std.mem.indexOf(u8, s, "thinking:adaptive") != null); try testing.expect(std.mem.indexOf(u8, s, "effort:xhigh") != null); } + // With pricing: a tag like "1i/5o/0.1r/1.25w" is appended to the line. + { + try pricing.set("openai", "gpt-4o", .{ + .input = 250, + .output = 1000, + .cache_read = 125, + .cache_write = 0, + }); + const d = testModelDef("openai", "gpt-4o", "gpt-4o"); + const s = try formatModelDetail(alloc, d, &pricing); + defer alloc.free(s); + try testing.expectEqualStrings("gpt-4o 2.5i/10o/1.25r/0w", s); + } } test "model picker rows sort by provider:alias and keep mapping" { @@ -3083,3 +3277,20 @@ test "splitEditorArgv: splits flags, appends the path, and falls back to vi" { try testing.expectEqualStrings("/tmp/y.md", argv.items[1]); } } + +test "session token bucket helper initializes new buckets at zero" { + var buckets: std.StringHashMapUnmanaged(u64) = .empty; + defer { + var kit = buckets.keyIterator(); + while (kit.next()) |k| testing.allocator.free(k.*); + buckets.deinit(testing.allocator); + } + + addTokenBucket(testing.allocator, &buckets, "anthropic:haiku", 5); + addTokenBucket(testing.allocator, &buckets, "anthropic:haiku", 7); + + var it = buckets.iterator(); + const entry = it.next() orelse unreachable; + try testing.expectEqualStrings("anthropic:haiku", entry.key_ptr.*); + try testing.expectEqual(@as(u64, 12), entry.value_ptr.*); +} diff --git a/src/tui_components.zig b/src/tui_components.zig index 14c61d9..4f74210 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -24,6 +24,8 @@ const component = @import("tui_component.zig"); const theme = @import("tui_theme.zig"); const input = @import("tui_input.zig"); const key = @import("tui_key.zig"); +const pricing_format = @import("pricing_format.zig"); +const markdown = @import("markdown.zig"); const Component = component.Component; const Focusable = component.Focusable; @@ -129,6 +131,57 @@ pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 { return text[0..i]; } +/// Like `displayWidth`, but treats ANSI CSI escape sequences as zero-width. +/// This is for already-styled text (markdown/diff output) that is composed +/// into blocks after wrapping. +pub fn displayWidthStyled(text: []const u8) usize { + var cols: usize = 0; + var i: usize = 0; + while (i < text.len) { + if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { + i += 2; + while (i < text.len) { + const c = text[i]; + i += 1; + if (c >= '@' and c <= '~') break; + } + continue; + } + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + cols += codepointWidth(cp); + i += adv; + } + return cols; +} + +/// ANSI-aware version of `truncateToCols`; never cuts inside a CSI sequence and +/// does not count escapes toward the column budget. +pub fn truncateStyledToCols(text: []const u8, max_cols: usize) []const u8 { + var cols: usize = 0; + var i: usize = 0; + while (i < text.len) { + if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { + i += 2; + while (i < text.len) { + const c = text[i]; + i += 1; + if (c >= '@' and c <= '~') break; + } + continue; + } + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + const w = codepointWidth(cp); + if (cols + w > max_cols) break; + i += adv; + cols += w; + } + return text[0..i]; +} + /// Wrap `text` (a single logical paragraph, no embedded newlines) into lines of /// at most `width` display columns, appending each produced line to `out`. /// Greedy word-wrap on ASCII spaces; a word longer than `width` is hard-split. @@ -254,13 +307,13 @@ fn stripAnsi(text: []const u8, out: *std.ArrayList(u8), alloc: std.mem.Allocator /// produces no lines. fn wrapBuffer(buffer: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void { if (buffer.len == 0) return; - // A single trailing newline is a line *terminator*, not an empty final - // line — strip it so `"a\nb\n"` wraps to two lines, not three. + // Newlines are semantic line breaks. Keep the trailing-empty-line behavior + // only for an ACTUAL freshly-typed final `\n`, not for every paragraph. const trimmed = if (buffer[buffer.len - 1] == '\n') buffer[0 .. buffer.len - 1] else buffer; if (trimmed.len == 0) return; var it = std.mem.splitScalar(u8, trimmed, '\n'); - while (it.next()) |para| { - try wrapParagraph(para, width, out, alloc); + while (it.next()) |line| { + try wrapParagraph(line, width, out, alloc); } } @@ -413,6 +466,25 @@ fn cacheLines(cache: *RenderCache) []const []const u8 { return @ptrCast(owned); } +/// Compact a token count: 845 -> "845", 1234 -> "1.2k", 12345 -> "12k", +/// 1_500_000 -> "1.5M". The `suffix` is appended to the result +/// (e.g. " ctx" or " tok"). Strips trailing ".0" so "1.0k" -> "1k". +/// Caller-owned `buf`; 16 bytes is more than enough. +pub fn formatTokenShort(buf: []u8, n: u64, suffix: []const u8) []const u8 { + if (n < 1000) return std.fmt.bufPrint(buf, "{d}{s}", .{ n, suffix }) catch ""; + if (n < 1_000_000) { + const tenths = (@as(u64, n) % 1000) / 100; // 0..=9 (one decimal) + const k = n / 1000; + if (tenths == 0) return std.fmt.bufPrint(buf, "{d}k{s}", .{ k, suffix }) catch ""; + return std.fmt.bufPrint(buf, "{d}.{d}k{s}", .{ k, tenths, suffix }) catch ""; + } + // M or higher — keep one decimal of the millions unit. + const m_int = n / 1_000_000; + const m_frac = (@as(u64, n) % 1_000_000) / 100_000; // 0..=9 + if (m_frac == 0) return std.fmt.bufPrint(buf, "{d}M{s}", .{ m_int, suffix }) catch ""; + return std.fmt.bufPrint(buf, "{d}.{d}M{s}", .{ m_int, m_frac, suffix }) catch ""; +} + // =========================================================================== // AssistantText — streaming assistant message (plan §6, §8) // =========================================================================== @@ -436,6 +508,13 @@ fn cacheLines(cache: *RenderCache) []const []const u8 { pub const AssistantText = struct { alloc: std.mem.Allocator, buffer: std.ArrayList(u8) = .empty, + /// True when the buffer is COMPLETE (set via `setText` from a + /// non-streaming source, e.g. conversation replay). The render + /// path then applies the markdown renderer to the WHOLE buffer + /// without the streaming-cut guard; the trailing partial-line + /// waiting-for-newline behavior applies only to `appendDelta`. + /// False on every `appendDelta`. + complete: bool = true, cache: RenderCache, pub fn init(alloc: std.mem.Allocator) AssistantText { @@ -452,26 +531,113 @@ pub const AssistantText = struct { /// firstLineChanged near the tail. pub fn appendDelta(self: *AssistantText, delta: []const u8) !void { try self.buffer.appendSlice(self.alloc, delta); + // Streaming mode: the trailing partial line is left for + // the next delta. The render path applies `streamingSafeCut` + // to the buffer. + self.complete = false; // markDirtyAppend RETAINS the baseline so the post-render diff recovers // the true tail change point; while dirty it reports a tail hint, so // the engine's cut stays near the end during streaming (plan §3.3/§8). self.cache.markDirtyAppend(); } + /// Finish a streaming assistant text block. This commits the final trailing + /// partial line (if any) so short/single-line replies render at block end. + pub fn finishStream(self: *AssistantText) void { + self.complete = true; + self.cache.markDirtyAppend(); + } + /// Replace the whole buffer (e.g. a non-streaming set). Marks dirty. + /// The render path renders the full buffer (no streaming cut) because + /// `setText` is the static-text path used by `seedFromConversation` + /// and similar code where the buffer is known-complete. pub fn setText(self: *AssistantText, text: []const u8) !void { self.buffer.clearRetainingCapacity(); try self.buffer.appendSlice(self.alloc, text); + self.complete = true; self.cache.markDirty(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *AssistantText = @ptrCast(@alignCast(ptr)); - // Assistant text: no background, but pad with margin lines and 1-col - // indent so it visually breathes alongside the background-filled blocks. - const plain_style = theme.default.fg(.assistant); - return renderBlockCached(&self.cache, self.buffer.items, plain_style, plain_style, width, 1, 0, self.alloc); + const a = self.alloc; + // Choose the prefix to render: + // - `setText` path (complete=true): the buffer is fully + // formed; render the whole thing with the markdown + // renderer. No streaming cut. + // - `appendDelta` path (complete=false): apply the + // streaming cut so the trailing partial line stays in + // the buffer for the next delta. + const cut: []const u8 = if (self.complete) + self.buffer.items + else + markdown.streamingSafeCut(self.buffer.items); + const tail: []const u8 = if (self.complete or cut.len >= self.buffer.items.len) + "" + else + self.buffer.items[cut.len..]; + if (cut.len == 0 and tail.len == 0) { + const empty: []const []const u8 = &.{}; + try self.cache.store(empty); + return cacheLines(&self.cache); + } + // Render the cut prefix into styled terminal lines. The + // renderer already does the inner word-wrap to `inner_w` + // cols, so each output line fits within the visible block. + const pad_x: usize = 1; + const inner_w = if (width > 2 * pad_x) width - 2 * pad_x else 1; + var lines: std.ArrayList([]const u8) = .empty; + defer { + for (lines.items) |l| a.free(l); + lines.deinit(a); + } + var r: markdown.Renderer = .{ + .alloc = a, + .width = inner_w, + .out_lines = &lines, + }; + try r.render(cut); + if (tail.len != 0) { + var tail_lines: std.ArrayList([]const u8) = .empty; + defer tail_lines.deinit(a); + try wrapBuffer(tail, inner_w, &tail_lines, a); + for (tail_lines.items) |tline| try lines.append(a, try a.dupe(u8, tline)); + } + + // Wrap each rendered line with the indent and right-pad to + // the block width. The assistant has no background, so the + // right pad is just whitespace. Escapes in the inner content + // are zero-width, so visible width == displayWidth(inner). + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| a.free(l); + out.deinit(a); + } + // Top margin. + try out.append(a, try a.dupe(u8, "")); + for (lines.items) |inner| { + const vis_cols = displayWidthStyled(inner); + const right_pad = width -| pad_x -| vis_cols; + const indent = try a.alloc(u8, pad_x); + defer a.free(indent); + @memset(indent, ' '); + const pad_str = try a.alloc(u8, right_pad); + defer a.free(pad_str); + @memset(pad_str, ' '); + const line = try std.fmt.allocPrint( + a, + "{s}{s}{s}", + .{ indent, inner, pad_str }, + ); + try out.append(a, line); + } + // Bottom margin. + try out.append(a, try a.dupe(u8, "")); + + try self.cache.store(out.items); + return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { @@ -525,10 +691,82 @@ pub const UserText = struct { fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *UserText = @ptrCast(@alignCast(ptr)); - // User messages: full-width gray background block with 1-col padding. - return renderBlockCached(&self.cache, self.buffer.items, - theme.default.fg(.user_bg), theme.default.fg(.user_text), - width, 1, 1, self.alloc); + const a = self.alloc; + // Static text: no streaming cut; the whole buffer is + // complete. Run the markdown renderer to get styled lines + // (or fall back to plain rendering when the buffer is + // empty). + const bg = theme.default.fg(.user_bg); + const fg = theme.default.fg(.user_text); + const pad_x: usize = 1; + const pad_y: usize = 1; + const inner_w = if (width > 2 * pad_x) width - 2 * pad_x else 1; + + if (self.buffer.items.len == 0) { + return renderBlockCached(&self.cache, "", bg, fg, width, pad_x, pad_y, a); + } + + // Render the markdown to styled lines, then compose each + // line with the user-bg fill. The user-bg block pads to + // `width` cols (bg fill extends to the right edge), with + // `pad_x` of left indent and `pad_y` blank rows inside the + // bg on top/bottom. + var lines: std.ArrayList([]const u8) = .empty; + defer { + for (lines.items) |l| a.free(l); + lines.deinit(a); + } + var r: markdown.Renderer = .{ + .alloc = a, + .width = inner_w, + .out_lines = &lines, + }; + try r.render(self.buffer.items); + + // Assemble the bg-styled output: top margin (no bg), pad_y + // blank bg rows, the rendered lines, pad_y blank bg rows, + // bottom margin (no bg). For each line, the inner content + // is composed as `bg.open ++ fg.open ++ indent + line + pad + // ++ reset`. + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| a.free(l); + out.deinit(a); + } + // A full-width bg-only line, reused for the pad_y rows. + const blank_bg_spaces = try a.alloc(u8, width); + defer a.free(blank_bg_spaces); + @memset(blank_bg_spaces, ' '); + const blank_bg = try std.fmt.allocPrint( + a, + "{s}{s}{s}", + .{ bg.open(), blank_bg_spaces, theme.reset }, + ); + defer a.free(blank_bg); + // Top margin (no bg). + try out.append(a, try a.dupe(u8, "")); + for (0..pad_y) |_| try out.append(a, try a.dupe(u8, blank_bg)); + const indent = try a.alloc(u8, pad_x); + defer a.free(indent); + @memset(indent, ' '); + for (lines.items) |inner| { + const vis_cols = displayWidthStyled(inner); + const right_pad = width -| pad_x -| vis_cols; + const pad_str = try a.alloc(u8, right_pad); + defer a.free(pad_str); + @memset(pad_str, ' '); + const composed = try std.fmt.allocPrint( + a, + "{s}{s}{s}{s}{s}{s}", + .{ bg.open(), fg.open(), indent, inner, pad_str, theme.reset }, + ); + try out.append(a, composed); + } + for (0..pad_y) |_| try out.append(a, try a.dupe(u8, blank_bg)); + // Bottom margin (no bg). + try out.append(a, try a.dupe(u8, "")); + try self.cache.store(out.items); + return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { @@ -1252,9 +1490,13 @@ pub const InputBox = struct { // Footer — persistent bottom line (plan §6) // =========================================================================== -/// The persistent bottom line. Renders model info and the latest context-window -/// token count, styled as dim chrome. `setModel(name)` sets the model info; -/// `setContextTokens(n)` updates the context readout. +/// The persistent bottom line. Renders model info and the latest +/// context-window token count, plus the running session token +/// total and the running session cost (when known), all styled as +/// dim chrome. `setModel(name)` sets the model info; +/// `setContextTokens(n)` updates the context readout; +/// `setSessionTokens(n)` / `setSessionCost(c)` update the +/// session-running readouts. pub const Footer = struct { alloc: std.mem.Allocator, cache: RenderCache, @@ -1265,6 +1507,17 @@ pub const Footer = struct { /// accumulated. Defined (plan §6) as /// `usage.input + usage.cache_read + usage.cache_write`. context_tokens: ?u64 = null, + /// Session-running sum of every reported `Usage`'s + /// `input + output + cache_read + cache_write`. Grew monotonically + /// across the session (including across model switches). Null until + /// the first turn reports usage. + session_tokens: ?u64 = null, + /// Session-running cost in micro-cents (the same unit `libpanto` + /// accumulates). Updated on each `message_complete` via + /// `panto.addCost`. Null while any turn has unknown pricing, or + /// until the first priced turn lands. The display layer formats + /// null as `"$unknown"`. + session_cost: ?u64 = null, pub fn init(alloc: std.mem.Allocator) Footer { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; @@ -1290,14 +1543,53 @@ pub const Footer = struct { self.cache.markDirty(); } + /// Set the session-running token total. The caller passes the + /// already-accumulated `sum of every turn's input + output + + /// cache_read + cache_write` (the display doesn't know about the + /// turn/category breakdown). Marked dirty so the footer repaints. + pub fn setSessionTokens(self: *Footer, tokens: ?u64) void { + if (self.session_tokens == tokens) return; + self.session_tokens = tokens; + self.cache.markDirty(); + } + + /// Set the session-running cost in micro-cents. `null` means + /// "unknown" (at least one turn had unknown pricing; the + /// per-pricing-field `null`s poison the total). The display + /// formats null as `"$unknown"`. + pub fn setSessionCost(self: *Footer, cost: ?u64) void { + if (self.session_cost == cost) return; + self.session_cost = cost; + self.cache.markDirty(); + } + /// Format the context-window element: e.g. "12.3k ctx" for large counts, /// "845 ctx" for small ones. "" (empty) when no usage reported yet, so the /// element is simply absent until the first `message_complete`. fn contextText(self: *const Footer, buf: []u8) []const u8 { const n = self.context_tokens orelse return ""; - if (n < 1000) return std.fmt.bufPrint(buf, "{d} ctx", .{n}) catch ""; - const k = @as(f64, @floatFromInt(n)) / 1000.0; - return std.fmt.bufPrint(buf, "{d:.1}k ctx", .{k}) catch ""; + return formatTokenShort(buf, n, " ctx"); + } + + /// Format the session token total: e.g. "1.2k tok", "12k tok", + /// "3.4M tok". "" (empty) when no usage reported yet. + fn sessionTokensText(self: *const Footer, buf: []u8) []const u8 { + const n = self.session_tokens orelse return ""; + return formatTokenShort(buf, n, " tok"); + } + + /// Format the session cost: "$1.23", "$0.06", or "$unknown" when + /// any priced component of any turn was null. The `$unknown` form + /// distinguishes "we have no price entry at all" from "the price + /// is exactly zero" (a known-zero that the user explicitly wrote + /// into models.toml). + fn sessionCostText(self: *const Footer, buf: []u8) []const u8 { + const c = self.session_cost orelse { + // Empty buf slot: we replace with the literal so the caller + // doesn't have to know about the special case. + return "$unknown"; + }; + return pricing_format.formatCostDollars(c, buf); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { @@ -1305,17 +1597,38 @@ pub const Footer = struct { const self: *Footer = @ptrCast(@alignCast(ptr)); const a = self.alloc; + // Format each optional segment in a small scratch buffer. The + // session cost buffer must be larger than the dollar form + // ("$12345678.90" = 14 chars) plus slack. var ctx_buf: [32]u8 = undefined; + var tok_buf: [32]u8 = undefined; + var cost_buf: [32]u8 = undefined; const ctx = self.contextText(&ctx_buf); + const tok = self.sessionTokensText(&tok_buf); + const cost = self.sessionCostText(&cost_buf); - // Build the PLAIN content: "<model> <ctx>" (each only when present). + // Build the PLAIN content as a fixed four-segment sequence, + // each separated by ` ` (three spaces). Segments that are + // empty (e.g. no usage yet) collapse cleanly because the + // leading and trailing separators are conditional on the next + // segment being non-empty. + // + // <model> <ctx> <session tokens> <session cost> var plain: std.ArrayList(u8) = .empty; defer plain.deinit(a); - if (self.model.items.len != 0) { - try plain.appendSlice(a, self.model.items); - if (ctx.len != 0) try plain.appendSlice(a, " "); + if (self.model.items.len != 0) try plain.appendSlice(a, self.model.items); + if (ctx.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, ctx); + } + if (tok.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, tok); + } + if (cost.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, cost); } - if (ctx.len != 0) try plain.appendSlice(a, ctx); const vis = truncateToCols(plain.items, width); @@ -1960,6 +2273,36 @@ pub const CompactionSummary = struct { // ToolUse — one component owns the whole call + result (plan §6, P2) // =========================================================================== +/// Format the header line for a tool call, given the tool's name and +/// Render the framework-default tool header. The Zig core intentionally does +/// not special-case extension tool names here: extension-provided tools own +/// their display by registering `panto.ext.on("tool"/...)` handlers and +/// calling `event:setComponent(...)` for their own names. +/// +/// Allocation: caller-owned; the returned slice is from `buf`. +fn formatToolHeader(name: []const u8, input_json: []const u8, buf: []u8) []const u8 { + if (std.mem.eql(u8, name, "std.shell") or std.mem.eql(u8, name, "std__shell")) { + const command = extractJsonStringField(input_json, "command") orelse input_json; + return std.fmt.bufPrint(buf, "tool ({s}) {s}", .{ name, command }) catch buf[0..0]; + } + return std.fmt.bufPrint(buf, "tool ({s}) {s}", .{ name, input_json }) catch buf[0..0]; +} + +fn extractJsonStringField(json: []const u8, field: []const u8) ?[]const u8 { + var pat_buf: [128]u8 = undefined; + const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\":", .{field}) catch return null; + const start = std.mem.indexOf(u8, json, pat) orelse return null; + var i = start + pat.len; + while (i < json.len and (json[i] == ' ' or json[i] == '\t' or json[i] == '\n' or json[i] == '\r')) : (i += 1) {} + if (i >= json.len or json[i] != '"') return null; + i += 1; + const val_start = i; + while (i < json.len) : (i += 1) { + if (json[i] == '"' and (i == val_start or json[i - 1] != '\\')) return json[val_start..i]; + } + return null; +} + /// A single component that owns an entire tool call: its name, its streamed /// input (verbatim JSON args), and its result output. Render progression /// (plan §6 / P2 table): @@ -2070,12 +2413,7 @@ pub const ToolUse = struct { // null -> pending (dark blue-gray) // true -> success (dark green) // false -> error (dark red) - const bg = if (self.result_ok == null) - theme.default.fg(.tool_pending_bg) - else if (self.result_ok.?) - theme.default.fg(.tool_success_bg) - else - theme.default.fg(.tool_error_bg); + const bg = theme.default.fg(.tool_pending_bg); const header_fg = theme.default.fg(.tool_header); const dim_fg = theme.default.fg(.dim); const plain_fg = theme.default.fg(.assistant); @@ -2092,7 +2430,7 @@ pub const ToolUse = struct { bg: Style, width: usize, fn line(ctx: @This(), fg: Style, text: []const u8) ![]u8 { - const text_cols = displayWidth(text); + const text_cols = displayWidthStyled(text); const right_pad_n = ctx.width -| (1 + text_cols); // 1 = left indent col const rp = try ctx.a.alloc(u8, right_pad_n); defer ctx.a.free(rp); @@ -2127,16 +2465,17 @@ pub const ToolUse = struct { return cacheLines(&self.cache); } - // -- Header: `tool (<name>) <args json>` --------------------------- + // -- Header: generic framework-default form. Extension-specific + // tool renderers should claim their own calls via the event bus. + var header_buf: [1024]u8 = undefined; + const header_text = formatToolHeader(self.name.?.items, self.input.items, &header_buf); // Top padding row inside the block. try lines.append(a, try ctx.blank()); { - const header_plain = try std.fmt.allocPrint(a, "tool ({s}) {s}", .{ self.name.?.items, self.input.items }); - defer a.free(header_plain); var wrapped: std.ArrayList([]const u8) = .empty; defer wrapped.deinit(a); - try wrapParagraph(header_plain, inner_w, &wrapped, a); + try wrapParagraph(header_text, inner_w, &wrapped, a); for (wrapped.items) |wline| { const vis = truncateToCols(wline, inner_w); try lines.append(a, try ctx.line(header_fg, vis)); @@ -2146,7 +2485,7 @@ pub const ToolUse = struct { // Separator blank row inside the block. try lines.append(a, try ctx.blank()); - // -- Result region: `(…)` placeholder or output text --------------- + // -- Result region: `(…)` placeholder or output text. if (self.output == null) { const vis = truncateToCols("(\xe2\x80\xa6)", inner_w); try lines.append(a, try ctx.line(dim_fg, vis)); @@ -2166,7 +2505,7 @@ pub const ToolUse = struct { try lines.append(a, try ctx.line(dim_fg, vis)); } for (out_lines.items[start..]) |oline| { - const vis = truncateToCols(oline, inner_w); + const vis = truncateStyledToCols(oline, inner_w); try lines.append(a, try ctx.line(plain_fg, vis)); } } @@ -2791,9 +3130,10 @@ test "Footer: contextText formatting boundaries (0, 999, 1000 -> k)" { // Just below the k threshold stays verbatim. ft.setContextTokens(999); try testing.expectEqualStrings("999 ctx", ft.contextText(&buf)); - // Exactly 1000 crosses into the k suffix. + // Exactly 1000 crosses into the k suffix. The compact form + // strips trailing zeros, so "1.0k" becomes "1k". ft.setContextTokens(1000); - try testing.expectEqualStrings("1.0k ctx", ft.contextText(&buf)); + try testing.expectEqualStrings("1k ctx", ft.contextText(&buf)); } test "Footer: large context token counts format as k; latest wins" { @@ -2804,7 +3144,7 @@ test "Footer: large context token counts format as k; latest wins" { try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); // Overwritten (latest-wins), not accumulated. ft.setContextTokens(2000); - try testing.expectEqualStrings("2.0k ctx", ft.contextText(&buf)); + try testing.expectEqualStrings("2k ctx", ft.contextText(&buf)); } test "Footer: setContextTokens dirties; stable re-render is clean" { @@ -2819,6 +3159,89 @@ test "Footer: setContextTokens dirties; stable re-render is clean" { try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged()); } +test "Footer: session token accumulator: absent until set, then displayed" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + try ft.setModel("m"); + var buf: [32]u8 = undefined; + // No setSessionTokens call yet => " tok" is absent from the render. + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], " tok") == null); + } + ft.setSessionTokens(12345); + try testing.expectEqualStrings("12.3k tok", ft.sessionTokensText(&buf)); + // Rendered alongside the model. + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], "12.3k tok") != null); + } + // Setting the same value is a no-op (no spurious dirty). + ft.setSessionTokens(12345); + try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged()); +} + +test "Footer: session cost: null -> '$unknown'; known value formats" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + // Default is null => "$unknown". + try testing.expectEqualStrings("$unknown", ft.sessionCostText(&buf)); + // 1 dollar = 100_000_000 micro-cents. + ft.setSessionCost(100_000_000); + try testing.expectEqualStrings("$1.00", ft.sessionCostText(&buf)); + // 60 cents. + ft.setSessionCost(60_000_000); + try testing.expectEqualStrings("$0.60", ft.sessionCostText(&buf)); +} + +test "Footer: full render shows model + ctx + session tokens + cost" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + try ft.setModel("anthropic:haiku (high)"); + ft.setContextTokens(8_000); + ft.setSessionTokens(125_000); + ft.setSessionCost(6_000_000); + const lines = try ft.comp().render(120, testing.allocator); + // All four segments present, separated by three spaces, in order. + const got = lines[0]; + try testing.expect(std.mem.indexOf(u8, got, "anthropic:haiku (high)") != null); + try testing.expect(std.mem.indexOf(u8, got, "8k ctx") != null); + try testing.expect(std.mem.indexOf(u8, got, "125k tok") != null); + try testing.expect(std.mem.indexOf(u8, got, "$0.06") != null); +} + +test "Footer: setSessionCost null on a previously known cost poisons back" { + // Defensive: the App's recorder never voluntarily re-poisons + // (the poison rule is one-way), but the setter accepts null + // and the display flips back to "$unknown". A test pins this + // contract. + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + ft.setSessionCost(6_000_000); + try testing.expectEqualStrings("$0.06", ft.sessionCostText(&buf)); + ft.setSessionCost(null); + try testing.expectEqualStrings("$unknown", ft.sessionCostText(&buf)); +} + +test "formatTokenShort: k and M boundaries, with and without a fractional" { + var buf: [16]u8 = undefined; + // Below the k threshold: raw integer. + try testing.expectEqualStrings("0", formatTokenShort(&buf, 0, "")); + try testing.expectEqualStrings("999", formatTokenShort(&buf, 999, "")); + // k threshold: trailing-zero fractional is stripped. + try testing.expectEqualStrings("1k", formatTokenShort(&buf, 1000, "")); + try testing.expectEqualStrings("1.2k", formatTokenShort(&buf, 1234, "")); + try testing.expectEqualStrings("12k", formatTokenShort(&buf, 12_000, "")); + try testing.expectEqualStrings("12.3k", formatTokenShort(&buf, 12_345, "")); + // M threshold. + try testing.expectEqualStrings("1M", formatTokenShort(&buf, 1_000_000, "")); + try testing.expectEqualStrings("1.5M", formatTokenShort(&buf, 1_500_000, "")); + // Suffix is appended. + try testing.expectEqualStrings("12k ctx", formatTokenShort(&buf, 12_000, " ctx")); +} + // -- Selector --------------------------------------------------------------- const sel_items = [_]SelectorItem{ @@ -2932,6 +3355,8 @@ test "components drive the real engine without a TTY" { defer footer.deinit(); try user.setText("hi there"); + // The assistant text is the streaming path: markdown renders all complete + // lines, and the trailing partial line is shown verbatim while it streams. try assistant.appendDelta("hello"); ib.setFocused(true); try ib.applyKey(charKey('q', "q")); @@ -2945,16 +3370,28 @@ test "components drive the real engine without a TTY" { try eng.render(); // first paint: must not error (width contract holds) const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "hi there") != null); + // The trailing partial line is shown immediately. try testing.expect(std.mem.indexOf(u8, out, "hello") != null); + // The closing newline commits the line; the next paint still shows it. + try assistant.appendDelta("\n"); + try eng.render(); + const out_after_newline = buf.written(); + try testing.expect(std.mem.indexOf(u8, out_after_newline, "hello") != null); // Cursor marker is consumed by the engine and recorded as a hint. try testing.expect(eng.cursor_hint != null); // Stream another delta -> only the assistant should re-render; the engine // stays on the differential path (no full clear after first paint). + // The trailing partial line is shown immediately, even before the closing + // newline arrives. try assistant.appendDelta(" world"); try footer.setModel("m2"); buf.clearRetainingCapacity(); try eng.render(); + const out_partial = buf.written(); + try testing.expect(std.mem.indexOf(u8, out_partial, "world") != null); + try assistant.appendDelta("\n"); + try eng.render(); const out2 = buf.written(); try testing.expect(std.mem.indexOf(u8, out2, "world") != null); } @@ -3036,18 +3473,19 @@ test "ToolUse: stage 1 renders tool (?) before the name resolves" { try testing.expect(found); } -test "ToolUse: stage 2 shows name + verbatim json + placeholder" { +test "ToolUse: stage 2 shows generic header + placeholder" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("read"); try t.appendInput("{\"path\":\"a\"}"); const lines = try t.comp().render(60, testing.allocator); - // margin+pad+header+sep+(\u2026)+pad+margin = 7 + // margin+pad+header+sep+placeholder+pad+margin = 7 try testing.expect(lines.len >= 7); - // Header is at lines[2] (top-margin + top-pad + header). + // The framework default does not special-case tool names. Extensions own + // their rendering by claiming their own tool events. try testing.expect(std.mem.indexOf(u8, lines[2], "tool (read) {\"path\":\"a\"}") != null); - // Placeholder (\u2026) is before bottom pad (lines[len-3]). - try testing.expect(std.mem.indexOf(u8, lines[lines.len - 3], "(…)") != null); + // Placeholder (U+2026) is before the bottom pad (lines[len-3]). + try testing.expect(std.mem.indexOf(u8, lines[lines.len - 3], "(\xe2\x80\xa6)") != null); for (lines) |l| try testing.expect(vw(l) <= 60); } diff --git a/src/tui_theme.zig b/src/tui_theme.zig index 9e7e565..038deb5 100644 --- a/src/tui_theme.zig +++ b/src/tui_theme.zig @@ -43,6 +43,14 @@ pub const StyleName = enum { err, /// Welcome banner accent (session start chrome). welcome, + /// Edit-diff `-` line prefix (slightly lighter red than `.err` + /// so the surrounding body text remains readable when the + /// prefix is the only colored byte on the line). + err_diff, + /// Edit-diff `+` line prefix (slightly lighter green than + /// `.tool_success_bg`; this is a FOREGROUND color used to + /// color the leading `+` byte in a diff, not a background). + add_diff, /// Thinking block body (dimmed, distinct entry so the taxonomy is honest /// even though it currently resolves to the same dim escape). thinking, @@ -123,6 +131,11 @@ fn styleFor(name: StyleName) Style { // Reverse video — the virtual cursor block. .cursor => .{ .open_seq = "\x1b[7m" }, .err => .{ .open_seq = "\x1b[31m" }, + // Diff -prefix: lighter red so the surrounding body text + // stays readable; only the leading byte is colored. + .err_diff => .{ .open_seq = "\x1b[38;2;240;100;100m" }, + // Diff +prefix: green, slightly muted. + .add_diff => .{ .open_seq = "\x1b[38;2;100;200;120m" }, // Welcome banner: cyan accent, matching the tool accent family. .welcome => .{ .open_seq = "\x1b[36m" }, // Thinking body: dimmed, like status chrome. diff --git a/tmp/pi_session_cost_analysis.json b/tmp/pi_session_cost_analysis.json deleted file mode 100644 index cc40030..0000000 --- a/tmp/pi_session_cost_analysis.json +++ /dev/null @@ -1,288 +0,0 @@ -{ - "root": "/Users/travis/Code/config/dot-pi/agent/sessions", - "sessions_scanned": 495, - "sessions_with_usage": 454, - "sessions_without_usage": 41, - "assistant_messages": 28636, - "assistant_messages_with_usage": 28636, - "parse_errors": 0, - "cache_active_sessions": 216, - "pooled_totals": { - "input": 648085868, - "output": 11386363, - "cache_read": 984042157, - "cache_write": 65075433 - }, - "pooled_metrics": { - "prompt_tokens": 1697203458, - "billable_tokens": 1708589821, - "prompt_to_output_ratio": 149.0558010490268, - "input_to_output_ratio": 56.91772412314626, - "cache_hit_rate": 0.9379712687878963, - "cache_coverage": 0.6181448576803453, - "prompt_reuse_rate": 0.5798021164531472, - "output_share": 0.006664187542294857, - "prompt_share": 0.9933358124577052, - "input_share": 0.37931038803724676, - "cache_read_share": 0.5759382064116839, - "cache_write_share": 0.0380872180087745 - }, - "recommended_weights": { - "pooled_token_mix": { - "input": 0.37931038803724676, - "output": 0.006664187542294857, - "cache_read": 0.5759382064116839, - "cache_write": 0.0380872180087745 - }, - "mean_session_mix": { - "input": 0.549819370457156, - "output": 0.05134274693943982, - "cache_read": 0.3229895728469125, - "cache_write": 0.07584830975649165 - }, - "median_session_mix": { - "input": 0.7903769253077484, - "output": 0.014961511819985934, - "cache_read": 0.0, - "cache_write": 0.0 - } - }, - "per_session_distributions": { - "billable_tokens": { - "count": 454, - "mean": 3763413.702643172, - "median": 315231.0, - "p10": 20182.100000000002, - "p90": 11804297.1, - "min": 2244, - "max": 101170942 - }, - "cache_coverage": { - "count": 454, - "mean": 0.40473111896757075, - "median": 0.0, - "p10": 0.0, - "p90": 0.9999679722217543, - "min": 0.0, - "max": 0.9999922748401895 - }, - "cache_hit_rate": { - "count": 216, - "mean": 0.8218859540276632, - "median": 0.9487389145525554, - "p10": 0.374890408374274, - "p90": 1.0, - "min": 0.0, - "max": 1.0 - }, - "cache_read_share": { - "count": 454, - "mean": 0.3229895728469125, - "median": 0.0, - "p10": 0.0, - "p90": 0.9389935967096287, - "min": 0.0, - "max": 0.9893885889087582 - }, - "cache_write_share": { - "count": 454, - "mean": 0.07584830975649165, - "median": 0.0, - "p10": 0.0, - "p90": 0.267090715504709, - "min": 0.0, - "max": 0.999839897534422 - }, - "input_share": { - "count": 454, - "mean": 0.549819370457156, - "median": 0.7903769253077484, - "p10": 3.1707600618505675e-05, - "p90": 0.9974440745899954, - "min": 7.709723608187813e-06, - "max": 0.9999909002957817 - }, - "input_to_output_ratio": { - "count": 454, - "mean": 829.6580689032315, - "median": 8.629654523169638, - "p10": 0.00421753985473397, - "p90": 409.9444444444443, - "min": 0.0010703582132153562, - "max": 109892.68181818182 - }, - "output_share": { - "count": 454, - "mean": 0.05134274693943982, - "median": 0.014961511819985934, - "p10": 0.0018531251145351706, - "p90": 0.16915366650275257, - "min": 9.099704218250615e-06, - "max": 0.5140911569511498 - }, - "output_tokens": { - "count": 454, - "mean": 25080.09471365639, - "median": 7470.5, - "p10": 365.8000000000001, - "p90": 78289.59999999998, - "min": 1, - "max": 279572 - }, - "prompt_reuse_rate": { - "count": 454, - "mean": 0.32683124761341736, - "median": 0.0, - "p10": 0.0, - "p90": 0.9461329490069706, - "min": 0.0, - "max": 0.9914821057091003 - }, - "prompt_share": { - "count": 454, - "mean": 0.9486572530605601, - "median": 0.985038488180014, - "p10": 0.8308463334972475, - "p90": 0.9981468748854648, - "min": 0.48590884304885024, - "max": 0.9999909002957817 - }, - "prompt_to_output_ratio": { - "count": 454, - "mean": 938.8894985366835, - "median": 65.83823653274615, - "p10": 4.91178438425, - "p90": 538.8489050453238, - "min": 0.9451803176902779, - "max": 109892.68181818182 - }, - "prompt_tokens": { - "count": 454, - "mean": 3738333.607929515, - "median": 308096.0, - "p10": 19041.5, - "p90": 11756748.7, - "min": 2149, - "max": 100968785 - } - }, - "cache_active_session_distributions": { - "cache_coverage": { - "count": 216, - "mean": 0.8506848519040607, - "median": 0.9997448273674421, - "p10": 0.43102818092852674, - "p90": 0.999982772379876, - "min": 0.0021478132287605567, - "max": 0.9999922748401895 - }, - "cache_hit_rate": { - "count": 216, - "mean": 0.8218859540276632, - "median": 0.9487389145525554, - "p10": 0.374890408374274, - "p90": 1.0, - "min": 0.0, - "max": 1.0 - }, - "cache_read_share": { - "count": 216, - "mean": 0.6788762318171216, - "median": 0.7882742441214121, - "p10": 0.15606025798620213, - "p90": 0.9641169584856126, - "min": 0.0, - "max": 0.9893885889087582 - }, - "cache_write_share": { - "count": 216, - "mean": 0.15942191032151487, - "median": 0.043386068762250346, - "p10": 0.0, - "p90": 0.5204273102542047, - "min": 0.0, - "max": 0.999839897534422 - }, - "prompt_reuse_rate": { - "count": 216, - "mean": 0.6869508630393124, - "median": 0.7947498412123791, - "p10": 0.15876630503361927, - "p90": 0.971281214123751, - "min": 0.0, - "max": 0.9914821057091003 - } - }, - "top_primary_providers": [ - [ - "omni", - 263 - ], - [ - "anthropic", - 110 - ], - [ - "pi-claude-cli", - 32 - ], - [ - "opencode", - 27 - ], - [ - "openai", - 10 - ], - [ - "nanogpt", - 9 - ], - [ - "github-copilot", - 3 - ] - ], - "top_primary_models": [ - [ - "claude-opus-4-7", - 100 - ], - [ - "claude-opus-4-8", - 77 - ], - [ - "claude-opus-4-6", - 46 - ], - [ - "opencode-zen/glm-5.1", - 41 - ], - [ - "glm-5.1", - 27 - ], - [ - "gpt-5.5-2026-04-23", - 24 - ], - [ - "claude-sonnet-4-6", - 21 - ], - [ - "gpt-5.4-2026-03-05", - 10 - ], - [ - "anthropic/claude-opus-4-7", - 10 - ], - [ - "gpt-5.5", - 9 - ] - ] -}
\ No newline at end of file |
