summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-15 22:11:53 -0600
committert <t@tjp.lol>2026-06-16 11:17:14 -0600
commitf8c6d45755acb5abf9ac68931268b7945da0fae0 (patch)
treea88ed7edd4aa3e5acced9ff99ac61b7f36b267a6
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
-rw-r--r--agent/extensions/std_render.lua193
-rw-r--r--agent/tools/edit.lua297
-rw-r--r--agent/tools/read.lua351
-rw-r--r--agent/tools/shell.lua40
-rw-r--r--agent/tools/write.lua101
-rw-r--r--build.zig20
-rw-r--r--build.zig.zon5
-rw-r--r--libpanto-c/src/lib.zig5
-rw-r--r--libpanto-lua/src/module.zig1
-rw-r--r--libpanto/src/agent.zig61
-rw-r--r--libpanto/src/pricing.zig87
-rw-r--r--libpanto/src/public.zig11
-rw-r--r--libpanto/src/stream.zig5
-rw-r--r--src/lua_event_bridge.zig82
-rw-r--r--src/main.zig5
-rw-r--r--src/markdown.zig332
-rw-r--r--src/pricing_format.zig214
-rw-r--r--src/tui_app.zig245
-rw-r--r--src/tui_components.zig526
-rw-r--r--src/tui_theme.zig13
-rw-r--r--tmp/pi_session_cost_analysis.json288
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
diff --git a/build.zig b/build.zig
index 87901c6..ceebee1 100644
--- a/build.zig
+++ b/build.zig
@@ -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, "&amp;")) return "&";
+ if (std.mem.eql(u8, s, "&lt;")) return "<";
+ if (std.mem.eql(u8, s, "&gt;")) return ">";
+ if (std.mem.eql(u8, s, "&quot;")) return "\"";
+ if (std.mem.eql(u8, s, "&apos;")) 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