summaryrefslogtreecommitdiff
path: root/src
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 /src
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'src')
-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
7 files changed, 1315 insertions, 102 deletions
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.