diff options
| author | t <t@tjp.lol> | 2026-06-15 22:11:53 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-16 11:17:14 -0600 |
| commit | f8c6d45755acb5abf9ac68931268b7945da0fae0 (patch) | |
| tree | a88ed7edd4aa3e5acced9ff99ac61b7f36b267a6 /src | |
| parent | 8206642d3a1736aaf928a5b9a732e747f5294228 (diff) | |
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'src')
| -rw-r--r-- | src/lua_event_bridge.zig | 82 | ||||
| -rw-r--r-- | src/main.zig | 5 | ||||
| -rw-r--r-- | src/markdown.zig | 332 | ||||
| -rw-r--r-- | src/pricing_format.zig | 214 | ||||
| -rw-r--r-- | src/tui_app.zig | 245 | ||||
| -rw-r--r-- | src/tui_components.zig | 526 | ||||
| -rw-r--r-- | src/tui_theme.zig | 13 |
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, "&")) return "&"; + if (std.mem.eql(u8, s, "<")) return "<"; + if (std.mem.eql(u8, s, ">")) return ">"; + if (std.mem.eql(u8, s, """)) return "\""; + if (std.mem.eql(u8, s, "'")) return "'"; + return s; +} + +const testing = std.testing; + +test "streamingSafeCut waits for newline" { + try testing.expectEqualStrings("", streamingSafeCut("hello")); + try testing.expectEqualStrings("foo\n", streamingSafeCut("foo\nbar")); +} + +test "wrapStyled wraps plain text" { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); + try wrapStyled("the quick brown fox", 10, &out, testing.allocator); + try testing.expectEqualStrings("the quick\nbrown fox", out.items); +} + +test "Renderer uses MD4C for common markdown" { + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| testing.allocator.free(l); + out.deinit(testing.allocator); + } + var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; + try r.render("# Heading\n\ncode `x` and **bold** and *italic* and [a](u).\n\n```zig\nfn main() {}\n```\n"); + try testing.expect(out.items.len > 3); + try testing.expect(std.mem.indexOf(u8, out.items[0], "Heading") != null); + var found_code = false; + for (out.items) |l| { + if (std.mem.indexOf(u8, l, "fn main") != null) found_code = true; + } + try testing.expect(found_code); +} diff --git a/src/pricing_format.zig b/src/pricing_format.zig new file mode 100644 index 0000000..daceff9 --- /dev/null +++ b/src/pricing_format.zig @@ -0,0 +1,214 @@ +//! Display formatters for `panto.Pricing` and accumulated session cost. +//! +//! libpanto stores prices as micro-cents per token; the human-friendly +//! unit is USD per million tokens (e.g. "3.0"). The formatters here +//! convert the integer representation to the display form, with two +//! shapes: +//! +//! - `formatPriceCompact` — the dense `"1i/5o/0.1r/1.25w"` form +//! used in the model picker, where the four rates are the only +//! content and the user reads them in a single glance. +//! - `formatCostDollars` — the canonical dollar amount +//! (`"$0.06"`, `"$1.23"`), used in the footer session total. +//! +//! The two have to make a few choices consistently: +//! +//! - Trailing zeros are dropped: "$3.00/Mtok" prints as `"3"`, not +//! `"3.00"`, and the compact form does the same. +//! - Sub-cent values round to the nearest cent, not to 0 or to a +//! half-cent (so `$0.005` becomes `$0.01`, matching what a bank +//! would say). +//! +//! The compact form omits any category whose price is `null` (unknown). +//! When ALL categories are unknown, the result is `null` and the +//! caller renders the bare model id (no price tag). + +const std = @import("std"); +const panto = @import("panto"); + +const Pricing = panto.Pricing; + +/// Suffixes for the compact form, in field order: input, output, +/// cache read, cache write. Known zeros still print (the +/// `cache_write = 0` convention for OpenAI), so the slot's presence +/// is determined by the field, not by its value. +const SUFFIXES = [_]u8{ 'i', 'o', 'r', 'w' }; + +/// The user-facing compact form for a single (provider, model) rate +/// set, e.g. "1i/5o/0.1r/1.25w". Returns `null` when EVERY field is +/// `null` (no pricing known) so the caller can fall back to a +/// model-only label without showing a dangling `?/?/?/?`. +/// +/// Allocation: caller-owned, written into `buf`. The 64-byte buffer +/// is more than enough — the worst case is four fields of up to +/// eight digits plus three slashes plus four suffix bytes. +pub fn formatPriceCompact(pricing: Pricing, buf: []u8) ?[]const u8 { + var out_len: usize = 0; + var emitted: usize = 0; + + const fields = [_]?u64{ pricing.input, pricing.output, pricing.cache_read, pricing.cache_write }; + var i: usize = 0; + while (i < 4) : (i += 1) { + const v = fields[i] orelse continue; + if (emitted != 0) { + if (out_len >= buf.len) return null; + buf[out_len] = '/'; + out_len += 1; + } + var scratch: [16]u8 = undefined; + const text = formatRate(v, &scratch); + if (out_len + text.len + 1 > buf.len) return null; + @memcpy(buf[out_len .. out_len + text.len], text); + out_len += text.len; + buf[out_len] = SUFFIXES[i]; + out_len += 1; + emitted += 1; + } + if (emitted == 0) return null; + return buf[0..out_len]; +} + +/// Format a single micro-cents-per-token value as a human price per +/// million tokens, e.g. 300 -> "3", 30 -> "0.3", 375 -> "3.75", +/// 5 -> "0.05". Strips trailing zeros (and a trailing decimal +/// point) so the result is the shortest accurate representation. +fn formatRate(micro_cents_per_token: u64, buf: []u8) []const u8 { + // We work with the integer directly: `value / 100` is whole + // dollars; `value % 100` is the cents fraction. Render the + // cents, then drop trailing zeros (and the dot if all zeros). + const whole = micro_cents_per_token / 100; + const frac = micro_cents_per_token % 100; + + // 16-byte buffer is more than enough for "99999999.99" (11 chars). + var w: usize = 0; + const whole_text = std.fmt.bufPrint(buf[w..], "{d}", .{whole}) catch return buf[0..0]; + w += whole_text.len; + if (frac != 0) { + buf[w] = '.'; + w += 1; + // Two-digit cents with a leading zero, e.g. 5 -> "05". + const cents_text = std.fmt.bufPrint(buf[w..], "{d:0>2}", .{frac}) catch return buf[0..0]; + w += cents_text.len; + // Drop trailing zeros: "0.30" -> "0.3", "0.05" stays. + while (w > 0 and buf[w - 1] == '0') w -= 1; + // If we dropped the last zero, also drop the dot. + if (w > 0 and buf[w - 1] == '.') w -= 1; + } + return buf[0..w]; +} + +/// Format an accumulated micro-cents total as a dollar amount +/// `"$X.YY"`. Sub-cent values round to the nearest cent; the +/// (rare) case where cents rounding overflows 100 folds to the +/// next dollar. +pub fn formatCostDollars(micro_cents_total: ?u64, buf: []u8) []const u8 { + const total = micro_cents_total orelse return buf[0..0]; + // 1 dollar = 100 cents = 100_000_000 micro-cents. Divide into + // whole dollars and a cents residue, then round the cents to + // the nearest cent. Pure integer arithmetic — no float drift. + const total_cents = total / 1_000_000; // 0.01 USD = 1 cent + const rounding = (total % 1_000_000) / 500_000; // 0 -> no round, 1 -> round up + const cents_total = total_cents + rounding; + const dollars = cents_total / 100; + const cents = cents_total % 100; + return std.fmt.bufPrint(buf, "${d}.{d:0>2}", .{ dollars, cents }) catch buf[0..0]; +} + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +test "formatPriceCompact: shows all four rates with minimal digits" { + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 100, .output = 500, .cache_read = 10, .cache_write = 125 }; + const got = formatPriceCompact(p, &buf).?; + // 100 micro-cents/token = $1.00/Mtok -> "1i" + // 500 -> $5.00 -> "5o" + // 10 -> $0.10 -> "0.1r" + // 125 -> $1.25 -> "1.25w" + try testing.expectEqualStrings("1i/5o/0.1r/1.25w", got); +} + +test "formatPriceCompact: omits unknown fields" { + var buf: [64]u8 = undefined; + const partial: Pricing = .{ .input = 300, .output = 1500 }; + const got = formatPriceCompact(partial, &buf).?; + try testing.expectEqualStrings("3i/15o", got); +} + +test "formatPriceCompact: all-unknown -> null" { + var buf: [64]u8 = undefined; + try testing.expect(formatPriceCompact(.{ .input = null, .output = null, .cache_read = null, .cache_write = null }, &buf) == null); +} + +test "formatPriceCompact: explicit zero prints as 0" { + // OpenAI's cache_write is a known zero, not unknown — must surface as + // "0w" rather than being silently dropped. + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 250, .output = 1000, .cache_read = 125, .cache_write = 0 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("2.5i/10o/1.25r/0w", got); +} + +test "formatPriceCompact: integer rates strip trailing .00" { + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 300, .output = 1500 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("3i/15o", got); + // No spurious ".00". + try testing.expect(std.mem.indexOf(u8, got, ".00") == null); +} + +test "formatPriceCompact: sub-cent rate keeps leading zero" { + // 0.05 -> "0.05" (not ".05" or "5e-2"). + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 5 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("0.05i", got); +} + +test "formatPriceCompact: sub-cent with trailing zero drops it" { + // 0.10 -> "0.1" (the trailing zero is dropped, but the leading 0 stays). + var buf: [64]u8 = undefined; + const p: Pricing = .{ .cache_read = 10 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("0.1r", got); +} + +test "formatPriceCompact: example given in the spec (haiku 4.5)" { + // 1i/5o/0.1r/1.25w per the user's example. + var buf: [64]u8 = undefined; + const p: Pricing = .{ .input = 100, .output = 500, .cache_read = 10, .cache_write = 125 }; + const got = formatPriceCompact(p, &buf).?; + try testing.expectEqualStrings("1i/5o/0.1r/1.25w", got); +} + +test "formatCostDollars: zero, sub-cent, whole-dollar, mixed" { + var buf: [32]u8 = undefined; + try testing.expectEqualStrings("$0.00", formatCostDollars(0, &buf)); + // 100_000 micro-cents = $0.001 -> rounded to nearest cent = $0.00. + // (The footer uses "X.YY" form; sub-cent values round.) + try testing.expectEqualStrings("$0.00", formatCostDollars(100_000, &buf)); + // 600_000 micro-cents = $0.006 -> $0.01. + try testing.expectEqualStrings("$0.01", formatCostDollars(600_000, &buf)); + // 6_000_000 micro-cents = $0.06 exactly. + try testing.expectEqualStrings("$0.06", formatCostDollars(6_000_000, &buf)); + // 100_000_000 micro-cents = $1.00. + try testing.expectEqualStrings("$1.00", formatCostDollars(100_000_000, &buf)); + // 123_450_000 micro-cents = $1.2345 -> $1.23. + try testing.expectEqualStrings("$1.23", formatCostDollars(123_450_000, &buf)); +} + +test "formatCostDollars: cent-rounding overflow folds to next dollar" { + // $0.9995 rounds to $1.00; verify the overflow path that nudges the + // dollars counter. + var buf: [32]u8 = undefined; + try testing.expectEqualStrings("$1.00", formatCostDollars(99_950_000, &buf)); +} + +test "formatCostDollars: null total returns empty" { + var buf: [32]u8 = undefined; + try testing.expectEqualStrings("", formatCostDollars(null, &buf)); +} diff --git a/src/tui_app.zig b/src/tui_app.zig index f9c58fe..e44a74a 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -67,6 +67,7 @@ const selectors_mod = @import("tui_selectors.zig"); const config_file = @import("config_file.zig"); const auth_manager = @import("auth_manager.zig"); const models_toml = @import("models_toml.zig"); +const pricing_format = @import("pricing_format.zig"); const tui_key = @import("tui_key.zig"); const Terminal = terminal_mod.Terminal; @@ -301,6 +302,15 @@ pub const App = struct { /// the picker overlays. selectors: ?*SelectorController = null, + /// Optional hook the App invokes on every `message_complete` carrying + /// a `Usage`. The hook (installed by the `SelectorController`) updates + /// session-running totals keyed by the current `(provider, model)` and + /// pushes the latest values into the footer. With no hook installed + /// (e.g. tests) the App still updates the per-turn context-window + /// tokens but does NOT accumulate session totals. + usage_record_ctx: ?*anyopaque = null, + usage_record_fn: ?*const fn (ctx: *anyopaque, usage: panto.Usage) void = null, + /// Whether the input box currently participates in the engine list. It is /// removed during an in-flight turn (so streaming output appends below the /// transcript) and re-added when the turn completes. P1 keeps it simple: @@ -358,6 +368,21 @@ pub const App = struct { self.flush_fn = f; } + /// Install the per-turn usage record hook (the `SelectorController` + /// calls this). On every `message_complete` the App invokes the + /// hook with the just-reported `Usage`; the hook keys the usage by + /// the current `(provider, model)` (its own concern) and pushes the + /// new session totals back into the footer. Tests omit this; the + /// per-turn context-window tokens still get updated. + pub fn setUsageRecorder( + self: *App, + ctx: *anyopaque, + f: *const fn (ctx: *anyopaque, usage: panto.Usage) void, + ) void { + self.usage_record_ctx = ctx; + self.usage_record_fn = f; + } + fn flushSink(self: *App) void { if (self.flush_fn) |f| f(self.flush_ctx.?); } @@ -866,6 +891,10 @@ pub const App = struct { .block_complete => |b| { switch (b.block) { .Text => { + if (self.router.get(b.index)) |ref| switch (ref) { + .assistant => |box| box.finishStream(), + else => {}, + }; try self.fireTextLifecycle(b.index, .assistant, .assistant_text_complete, "assistant_text_complete", .{ .assistant_text = .{ .index = b.index, .text = if (self.router.get(b.index)) |r| (if (r == .assistant) r.assistant.buffer.items else "") else "", @@ -915,8 +944,13 @@ pub const App = struct { // (output/reasoning excluded — not "in the window"). Latest // value wins; not accumulated. if (mc.usage) |u| { - const ctx = u.input + u.cache_read + u.cache_write + u.output; + const ctx = u.input + u.cache_read + u.cache_write; self.footer.setContextTokens(ctx); + // Hand the raw usage to the recorder (the + // `SelectorController` is the canonical recorder; it + // knows which `(provider, model)` produced this turn + // and accumulates per-model token + cost totals). + if (self.usage_record_fn) |f| f(self.usage_record_ctx.?, u); self.scheduler.requestRender(); } }, @@ -939,6 +973,11 @@ pub const App = struct { } self.scheduler.requestRender(); }, + .tool_dispatch_result => |info| { + // Eager per-tool result carrier. Correlate by tool_use_id just + // like the aggregate completion event. + try self.routeToolResults(info.message); + }, .tool_dispatch_complete => |info| { // ToolResult blocks are delivered together here as the content // of the appended user message. Correlate each back to its @@ -1019,6 +1058,20 @@ fn modelPickerRowLessThan(_: void, a: ModelPickerRow, b: ModelPickerRow) bool { return std.mem.lessThan(u8, a.item.detail, b.item.detail); } +fn addTokenBucket( + alloc: std.mem.Allocator, + buckets: *std.StringHashMapUnmanaged(u64), + key: []const u8, + amount: u64, +) void { + const gop = buckets.getOrPut(alloc, key) catch return; + if (!gop.found_existing) { + gop.key_ptr.* = alloc.dupe(u8, key) catch return; + gop.value_ptr.* = 0; + } + gop.value_ptr.* +%= amount; +} + pub const SelectorController = struct { alloc: std.mem.Allocator, app: *App, @@ -1027,6 +1080,11 @@ pub const SelectorController = struct { /// transport/auth and per-alias knobs lookups via `buildProviderConfig`. file_cfg: *const config_file.Config, defs: *const models_toml.ModelRegistry, + /// Per-(provider, wire-model) pricing table for the session. Borrowed + /// (the merged `models_toml.Models` outlives the controller). The + /// model-picker detail line and the footer session cost both look up + /// here; the controller does NOT mutate it. + pricing: *const panto.PricingRegistry, /// The live agent config snapshot, owned here. `agent` holds a pointer to /// it; we mutate `provider` in place and re-`setConfig` so the change is /// observed at the next turn. @@ -1056,12 +1114,35 @@ pub const SelectorController = struct { /// The current model label ("provider:alias"), for the footer/preselect. model_label: []u8, + /// Session-running token totals, keyed by the `(provider, model)` + /// pair that produced them. A model switch in the middle of a + /// session just appends a new bucket; the SUM is what the footer + /// displays. Each entry is owned here and is the integer sum of + /// `input + output + cache_read + cache_write` for the turns + /// produced by that model. + /// + /// WHY PER-MODEL: the user can switch models mid-session; a + /// flat u64 would conflate two models' tokens. Per-model lets us + /// re-pricing: if the user pastes a corrected `models.toml` mid- + /// session we just rebuild the cost (the next addCost call picks + /// up the new pricing), and the token sum is the *same* flat + /// total regardless of pricing changes. + session_token_buckets: std.StringHashMapUnmanaged(u64) = .empty, + + /// Session-running cost total in micro-cents. `null` means "at + /// least one priced component of at least one turn was unknown" + /// (the `addCost` poison rule). Set to 0 for the first + /// ALL-ZERO turn and stays known thereafter; any later + /// `costMicroCents == null` re-poisons. + session_cost: ?u64 = 0, + pub fn init( alloc: std.mem.Allocator, app: *App, agent: *panto.Agent, file_cfg: *const config_file.Config, defs: *const models_toml.ModelRegistry, + pricing: *const panto.PricingRegistry, live: *panto.Config, initial_label: []const u8, ) !*SelectorController { @@ -1073,12 +1154,17 @@ pub const SelectorController = struct { .agent = agent, .file_cfg = file_cfg, .defs = defs, + .pricing = pricing, .live = live, .model_items = &.{}, .model_label = try alloc.dupe(u8, initial_label), }; try self.buildModelItems(); try self.refreshFooter(); + // Install ourselves as the App's per-turn usage recorder so every + // `message_complete` lands in our session-running buckets and the + // footer session cost/token total is updated. + app.setUsageRecorder(self, recordUsageThunk); return self; } @@ -1105,6 +1191,10 @@ pub const SelectorController = struct { self.alloc.free(self.model_entry_indices); self.alloc.free(self.reasoning_items); self.alloc.free(self.model_label); + // The session token buckets own the (provider, model) key strings. + var it = self.session_token_buckets.iterator(); + while (it.next()) |entry| self.alloc.free(entry.key_ptr.*); + self.session_token_buckets.deinit(self.alloc); self.alloc.destroy(self); } @@ -1116,7 +1206,7 @@ pub const SelectorController = struct { for (self.defs.entries.items, 0..) |d, def_index| { const label = try std.fmt.allocPrint(a, "{s}:{s}", .{ d.provider, d.alias }); try self.model_strings.append(a, label); - const detail = try formatModelDetail(a, d); + const detail = try formatModelDetail(a, d, self.pricing); try self.model_strings.append(a, detail); try rows.append(a, .{ .def_index = def_index, .item = .{ .label = label, .detail = detail } }); } @@ -1246,14 +1336,91 @@ pub const SelectorController = struct { defer self.alloc.free(msg); _ = self.app.spawnStatus(msg) catch {}; } + + /// Record one turn's `usage` against the current `(provider, model)`, + /// then push the new session totals into the footer. Model-switch + /// tolerance: each `(provider, model)` is a separate bucket for + /// the per-model token sum; the FOOTER displays the flat sum + /// across all buckets, so a switch in the middle of a session is + /// invisible to the user (it just makes the next turn's tokens + /// land in a new bucket). For cost, the same `addCost` accumulator + /// is used; the per-turn cost is computed against THIS turn's + /// `(provider, model)` pricing, so a model with known pricing + /// before an unpriced model after still produces a known total + /// only up to the switch. + fn recordUsage(self: *SelectorController, usage: panto.Usage) void { + // Resolve the (provider, wire-model) pair for THIS turn. The + // pricing registry is keyed on these strings; the bucket + // string is "<provider>:<wire>" so a model name that happens + // to be reused across providers doesn't collide. + const colon = std.mem.indexOfScalar(u8, self.model_label, ':') orelse { + // A malformed label (no colon) is a programmer error in + // the boot sequence; just bail. The App still shows the + // per-turn context-window tokens. + return; + }; + const provider_name = self.model_label[0..colon]; + const wire_model = selectors_mod.wireModel(self.live.provider); + + const turn_tokens = usage.input + usage.output + usage.cache_read + usage.cache_write; + const key = std.fmt.allocPrint(self.alloc, "{s}:{s}", .{ provider_name, wire_model }) catch return; + defer self.alloc.free(key); + addTokenBucket(self.alloc, &self.session_token_buckets, key, turn_tokens); + + // Recompute the flat session sum. Cheap: the bucket count is + // tiny (the user doesn't switch models a thousand times). + var total: u64 = 0; + var it = self.session_token_buckets.iterator(); + while (it.next()) |entry| total +%= entry.value_ptr.*; + self.app.footer.setSessionTokens(total); + + // Cost: look up the pricing for the model that JUST produced + // this usage. If any priced component is null (no models.toml + // entry for this model), this turn's cost is null and + // `addCost` poisons the session total to null — once poisoned, + // it stays null for the rest of the session (no way to + // "un-poison" a `?u64` back to a known value). + // + // An empty (default-constructed) Pricing has every field null; + // `costMicroCents` treats every nonzero token usage as + // "unknown" and returns null. That's the same as the + // no-pricing case, so a single code path covers both. + const turn_cost: ?u64 = if (self.pricing.get(provider_name, wire_model)) |pricing| + panto.costMicroCents(usage, pricing) + else + null; + self.session_cost = panto.addCost(self.session_cost, turn_cost); + self.app.footer.setSessionCost(self.session_cost); + } + + /// Static thunk for the App's `setUsageRecorder` callback. The + /// `ctx` we install with is the `*SelectorController` itself; the + /// thunk casts it back and dispatches to the typed method. + fn recordUsageThunk(ctx: *anyopaque, usage: panto.Usage) void { + const self: *SelectorController = @ptrCast(@alignCast(ctx)); + self.recordUsage(usage); + } }; -/// Format the dim detail string for a model item: the wire model id plus the -/// reasoning/thinking knobs declared in `models.toml`. -fn formatModelDetail(alloc: std.mem.Allocator, d: models_toml.ModelDef) ![]u8 { - // Anthropic-style entries advertise thinking/effort; openai-style ones - // advertise reasoning. We don't know the provider's API style here, so we - // show whatever knobs are non-default. + +/// Format the dim detail string for a model item: the wire model id, +/// the reasoning/thinking knobs declared in `models.toml`, and the +/// pricing (when known). The pricing is looked up by the wire model +/// id against the parsed `PricingRegistry` (the same keying the +/// session cost accumulator uses); it surfaces as a compact +/// `"1i/5o/0.1r/1.25w"` suffix on the same line so the picker row +/// is a one-glance summary. +/// +/// Anthropic-style entries advertise thinking/effort; openai-style +/// ones advertise reasoning. We don't know the provider's API style +/// here, so we show whatever knobs are non-default. Pricing is +/// appended last so the most-novel information lands closest to the +/// model label and the older knob info reads as supporting context. +fn formatModelDetail( + alloc: std.mem.Allocator, + d: models_toml.ModelDef, + pricing: *const panto.PricingRegistry, +) ![]u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(alloc); try buf.appendSlice(alloc, d.model); @@ -1268,6 +1435,13 @@ fn formatModelDetail(alloc: std.mem.Allocator, d: models_toml.ModelDef) ![]u8 { try buf.appendSlice(alloc, " reasoning:"); try buf.appendSlice(alloc, @tagName(d.reasoning)); } + if (pricing.get(d.provider, d.model)) |p| { + var scratch: [64]u8 = undefined; + if (pricing_format.formatPriceCompact(p, &scratch)) |tag| { + try buf.appendSlice(alloc, " "); + try buf.appendSlice(alloc, tag); + } + } return buf.toOwnedSlice(alloc); } @@ -2030,13 +2204,15 @@ test "routeEvent: full event stream renders through the real engine, no stdout" h.app.beginTurn(); try h.app.routeEvent(.{ .message_start = .assistant }); try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } }); + // The streaming path renders complete lines through markdown and shows the + // trailing partial line verbatim as it arrives. try h.app.routeEvent(delta(0, "Hi there")); - try h.app.routeEvent(.{ .turn_complete = {} }); - try h.app.renderNow(); - const out = h.buf.written(); - // The assistant text reached the engine output (not stdout). - try testing.expect(std.mem.indexOf(u8, out, "Hi there") != null); + try testing.expect(std.mem.indexOf(u8, h.buf.written(), "Hi there") != null); + try h.app.routeEvent(delta(0, "\n")); + try h.app.renderNow(); + try testing.expect(std.mem.indexOf(u8, h.buf.written(), "Hi there") != null); + try h.app.routeEvent(.{ .turn_complete = {} }); } test "beginTurn clears the block-index map but keeps transcript history" { @@ -2306,12 +2482,17 @@ fn testModelDef(provider: []const u8, alias: []const u8, model: []const u8) mode }; } -test "formatModelDetail: shows wire model + non-default knobs" { +test "formatModelDetail: shows wire model + non-default knobs + pricing" { const alloc = testing.allocator; + // Empty pricing registry: no tag appended (this is the default shape for + // test definitions that don't bother declaring a price). + var pricing = panto.PricingRegistry.init(alloc); + defer pricing.deinit(); + // Plain entry: just the wire model id. { const d = testModelDef("openai", "gpt", "gpt-4o"); - const s = try formatModelDetail(alloc, d); + const s = try formatModelDetail(alloc, d, &pricing); defer alloc.free(s); try testing.expectEqualStrings("gpt-4o", s); } @@ -2319,7 +2500,7 @@ test "formatModelDetail: shows wire model + non-default knobs" { { var d = testModelDef("openai", "o3", "o3"); d.reasoning = .high; - const s = try formatModelDetail(alloc, d); + const s = try formatModelDetail(alloc, d, &pricing); defer alloc.free(s); try testing.expect(std.mem.indexOf(u8, s, "reasoning:high") != null); } @@ -2328,11 +2509,24 @@ test "formatModelDetail: shows wire model + non-default knobs" { var d = testModelDef("anthropic", "opus", "claude-opus-4"); d.thinking = .adaptive; d.effort = .xhigh; - const s = try formatModelDetail(alloc, d); + const s = try formatModelDetail(alloc, d, &pricing); defer alloc.free(s); try testing.expect(std.mem.indexOf(u8, s, "thinking:adaptive") != null); try testing.expect(std.mem.indexOf(u8, s, "effort:xhigh") != null); } + // With pricing: a tag like "1i/5o/0.1r/1.25w" is appended to the line. + { + try pricing.set("openai", "gpt-4o", .{ + .input = 250, + .output = 1000, + .cache_read = 125, + .cache_write = 0, + }); + const d = testModelDef("openai", "gpt-4o", "gpt-4o"); + const s = try formatModelDetail(alloc, d, &pricing); + defer alloc.free(s); + try testing.expectEqualStrings("gpt-4o 2.5i/10o/1.25r/0w", s); + } } test "model picker rows sort by provider:alias and keep mapping" { @@ -3083,3 +3277,20 @@ test "splitEditorArgv: splits flags, appends the path, and falls back to vi" { try testing.expectEqualStrings("/tmp/y.md", argv.items[1]); } } + +test "session token bucket helper initializes new buckets at zero" { + var buckets: std.StringHashMapUnmanaged(u64) = .empty; + defer { + var kit = buckets.keyIterator(); + while (kit.next()) |k| testing.allocator.free(k.*); + buckets.deinit(testing.allocator); + } + + addTokenBucket(testing.allocator, &buckets, "anthropic:haiku", 5); + addTokenBucket(testing.allocator, &buckets, "anthropic:haiku", 7); + + var it = buckets.iterator(); + const entry = it.next() orelse unreachable; + try testing.expectEqualStrings("anthropic:haiku", entry.key_ptr.*); + try testing.expectEqual(@as(u64, 12), entry.value_ptr.*); +} diff --git a/src/tui_components.zig b/src/tui_components.zig index 14c61d9..4f74210 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -24,6 +24,8 @@ const component = @import("tui_component.zig"); const theme = @import("tui_theme.zig"); const input = @import("tui_input.zig"); const key = @import("tui_key.zig"); +const pricing_format = @import("pricing_format.zig"); +const markdown = @import("markdown.zig"); const Component = component.Component; const Focusable = component.Focusable; @@ -129,6 +131,57 @@ pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 { return text[0..i]; } +/// Like `displayWidth`, but treats ANSI CSI escape sequences as zero-width. +/// This is for already-styled text (markdown/diff output) that is composed +/// into blocks after wrapping. +pub fn displayWidthStyled(text: []const u8) usize { + var cols: usize = 0; + var i: usize = 0; + while (i < text.len) { + if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { + i += 2; + while (i < text.len) { + const c = text[i]; + i += 1; + if (c >= '@' and c <= '~') break; + } + continue; + } + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + cols += codepointWidth(cp); + i += adv; + } + return cols; +} + +/// ANSI-aware version of `truncateToCols`; never cuts inside a CSI sequence and +/// does not count escapes toward the column budget. +pub fn truncateStyledToCols(text: []const u8, max_cols: usize) []const u8 { + var cols: usize = 0; + var i: usize = 0; + while (i < text.len) { + if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { + i += 2; + while (i < text.len) { + const c = text[i]; + i += 1; + if (c >= '@' and c <= '~') break; + } + continue; + } + const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + const w = codepointWidth(cp); + if (cols + w > max_cols) break; + i += adv; + cols += w; + } + return text[0..i]; +} + /// Wrap `text` (a single logical paragraph, no embedded newlines) into lines of /// at most `width` display columns, appending each produced line to `out`. /// Greedy word-wrap on ASCII spaces; a word longer than `width` is hard-split. @@ -254,13 +307,13 @@ fn stripAnsi(text: []const u8, out: *std.ArrayList(u8), alloc: std.mem.Allocator /// produces no lines. fn wrapBuffer(buffer: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void { if (buffer.len == 0) return; - // A single trailing newline is a line *terminator*, not an empty final - // line — strip it so `"a\nb\n"` wraps to two lines, not three. + // Newlines are semantic line breaks. Keep the trailing-empty-line behavior + // only for an ACTUAL freshly-typed final `\n`, not for every paragraph. const trimmed = if (buffer[buffer.len - 1] == '\n') buffer[0 .. buffer.len - 1] else buffer; if (trimmed.len == 0) return; var it = std.mem.splitScalar(u8, trimmed, '\n'); - while (it.next()) |para| { - try wrapParagraph(para, width, out, alloc); + while (it.next()) |line| { + try wrapParagraph(line, width, out, alloc); } } @@ -413,6 +466,25 @@ fn cacheLines(cache: *RenderCache) []const []const u8 { return @ptrCast(owned); } +/// Compact a token count: 845 -> "845", 1234 -> "1.2k", 12345 -> "12k", +/// 1_500_000 -> "1.5M". The `suffix` is appended to the result +/// (e.g. " ctx" or " tok"). Strips trailing ".0" so "1.0k" -> "1k". +/// Caller-owned `buf`; 16 bytes is more than enough. +pub fn formatTokenShort(buf: []u8, n: u64, suffix: []const u8) []const u8 { + if (n < 1000) return std.fmt.bufPrint(buf, "{d}{s}", .{ n, suffix }) catch ""; + if (n < 1_000_000) { + const tenths = (@as(u64, n) % 1000) / 100; // 0..=9 (one decimal) + const k = n / 1000; + if (tenths == 0) return std.fmt.bufPrint(buf, "{d}k{s}", .{ k, suffix }) catch ""; + return std.fmt.bufPrint(buf, "{d}.{d}k{s}", .{ k, tenths, suffix }) catch ""; + } + // M or higher — keep one decimal of the millions unit. + const m_int = n / 1_000_000; + const m_frac = (@as(u64, n) % 1_000_000) / 100_000; // 0..=9 + if (m_frac == 0) return std.fmt.bufPrint(buf, "{d}M{s}", .{ m_int, suffix }) catch ""; + return std.fmt.bufPrint(buf, "{d}.{d}M{s}", .{ m_int, m_frac, suffix }) catch ""; +} + // =========================================================================== // AssistantText — streaming assistant message (plan §6, §8) // =========================================================================== @@ -436,6 +508,13 @@ fn cacheLines(cache: *RenderCache) []const []const u8 { pub const AssistantText = struct { alloc: std.mem.Allocator, buffer: std.ArrayList(u8) = .empty, + /// True when the buffer is COMPLETE (set via `setText` from a + /// non-streaming source, e.g. conversation replay). The render + /// path then applies the markdown renderer to the WHOLE buffer + /// without the streaming-cut guard; the trailing partial-line + /// waiting-for-newline behavior applies only to `appendDelta`. + /// False on every `appendDelta`. + complete: bool = true, cache: RenderCache, pub fn init(alloc: std.mem.Allocator) AssistantText { @@ -452,26 +531,113 @@ pub const AssistantText = struct { /// firstLineChanged near the tail. pub fn appendDelta(self: *AssistantText, delta: []const u8) !void { try self.buffer.appendSlice(self.alloc, delta); + // Streaming mode: the trailing partial line is left for + // the next delta. The render path applies `streamingSafeCut` + // to the buffer. + self.complete = false; // markDirtyAppend RETAINS the baseline so the post-render diff recovers // the true tail change point; while dirty it reports a tail hint, so // the engine's cut stays near the end during streaming (plan §3.3/§8). self.cache.markDirtyAppend(); } + /// Finish a streaming assistant text block. This commits the final trailing + /// partial line (if any) so short/single-line replies render at block end. + pub fn finishStream(self: *AssistantText) void { + self.complete = true; + self.cache.markDirtyAppend(); + } + /// Replace the whole buffer (e.g. a non-streaming set). Marks dirty. + /// The render path renders the full buffer (no streaming cut) because + /// `setText` is the static-text path used by `seedFromConversation` + /// and similar code where the buffer is known-complete. pub fn setText(self: *AssistantText, text: []const u8) !void { self.buffer.clearRetainingCapacity(); try self.buffer.appendSlice(self.alloc, text); + self.complete = true; self.cache.markDirty(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *AssistantText = @ptrCast(@alignCast(ptr)); - // Assistant text: no background, but pad with margin lines and 1-col - // indent so it visually breathes alongside the background-filled blocks. - const plain_style = theme.default.fg(.assistant); - return renderBlockCached(&self.cache, self.buffer.items, plain_style, plain_style, width, 1, 0, self.alloc); + const a = self.alloc; + // Choose the prefix to render: + // - `setText` path (complete=true): the buffer is fully + // formed; render the whole thing with the markdown + // renderer. No streaming cut. + // - `appendDelta` path (complete=false): apply the + // streaming cut so the trailing partial line stays in + // the buffer for the next delta. + const cut: []const u8 = if (self.complete) + self.buffer.items + else + markdown.streamingSafeCut(self.buffer.items); + const tail: []const u8 = if (self.complete or cut.len >= self.buffer.items.len) + "" + else + self.buffer.items[cut.len..]; + if (cut.len == 0 and tail.len == 0) { + const empty: []const []const u8 = &.{}; + try self.cache.store(empty); + return cacheLines(&self.cache); + } + // Render the cut prefix into styled terminal lines. The + // renderer already does the inner word-wrap to `inner_w` + // cols, so each output line fits within the visible block. + const pad_x: usize = 1; + const inner_w = if (width > 2 * pad_x) width - 2 * pad_x else 1; + var lines: std.ArrayList([]const u8) = .empty; + defer { + for (lines.items) |l| a.free(l); + lines.deinit(a); + } + var r: markdown.Renderer = .{ + .alloc = a, + .width = inner_w, + .out_lines = &lines, + }; + try r.render(cut); + if (tail.len != 0) { + var tail_lines: std.ArrayList([]const u8) = .empty; + defer tail_lines.deinit(a); + try wrapBuffer(tail, inner_w, &tail_lines, a); + for (tail_lines.items) |tline| try lines.append(a, try a.dupe(u8, tline)); + } + + // Wrap each rendered line with the indent and right-pad to + // the block width. The assistant has no background, so the + // right pad is just whitespace. Escapes in the inner content + // are zero-width, so visible width == displayWidth(inner). + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| a.free(l); + out.deinit(a); + } + // Top margin. + try out.append(a, try a.dupe(u8, "")); + for (lines.items) |inner| { + const vis_cols = displayWidthStyled(inner); + const right_pad = width -| pad_x -| vis_cols; + const indent = try a.alloc(u8, pad_x); + defer a.free(indent); + @memset(indent, ' '); + const pad_str = try a.alloc(u8, right_pad); + defer a.free(pad_str); + @memset(pad_str, ' '); + const line = try std.fmt.allocPrint( + a, + "{s}{s}{s}", + .{ indent, inner, pad_str }, + ); + try out.append(a, line); + } + // Bottom margin. + try out.append(a, try a.dupe(u8, "")); + + try self.cache.store(out.items); + return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { @@ -525,10 +691,82 @@ pub const UserText = struct { fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *UserText = @ptrCast(@alignCast(ptr)); - // User messages: full-width gray background block with 1-col padding. - return renderBlockCached(&self.cache, self.buffer.items, - theme.default.fg(.user_bg), theme.default.fg(.user_text), - width, 1, 1, self.alloc); + const a = self.alloc; + // Static text: no streaming cut; the whole buffer is + // complete. Run the markdown renderer to get styled lines + // (or fall back to plain rendering when the buffer is + // empty). + const bg = theme.default.fg(.user_bg); + const fg = theme.default.fg(.user_text); + const pad_x: usize = 1; + const pad_y: usize = 1; + const inner_w = if (width > 2 * pad_x) width - 2 * pad_x else 1; + + if (self.buffer.items.len == 0) { + return renderBlockCached(&self.cache, "", bg, fg, width, pad_x, pad_y, a); + } + + // Render the markdown to styled lines, then compose each + // line with the user-bg fill. The user-bg block pads to + // `width` cols (bg fill extends to the right edge), with + // `pad_x` of left indent and `pad_y` blank rows inside the + // bg on top/bottom. + var lines: std.ArrayList([]const u8) = .empty; + defer { + for (lines.items) |l| a.free(l); + lines.deinit(a); + } + var r: markdown.Renderer = .{ + .alloc = a, + .width = inner_w, + .out_lines = &lines, + }; + try r.render(self.buffer.items); + + // Assemble the bg-styled output: top margin (no bg), pad_y + // blank bg rows, the rendered lines, pad_y blank bg rows, + // bottom margin (no bg). For each line, the inner content + // is composed as `bg.open ++ fg.open ++ indent + line + pad + // ++ reset`. + var out: std.ArrayList([]const u8) = .empty; + defer { + for (out.items) |l| a.free(l); + out.deinit(a); + } + // A full-width bg-only line, reused for the pad_y rows. + const blank_bg_spaces = try a.alloc(u8, width); + defer a.free(blank_bg_spaces); + @memset(blank_bg_spaces, ' '); + const blank_bg = try std.fmt.allocPrint( + a, + "{s}{s}{s}", + .{ bg.open(), blank_bg_spaces, theme.reset }, + ); + defer a.free(blank_bg); + // Top margin (no bg). + try out.append(a, try a.dupe(u8, "")); + for (0..pad_y) |_| try out.append(a, try a.dupe(u8, blank_bg)); + const indent = try a.alloc(u8, pad_x); + defer a.free(indent); + @memset(indent, ' '); + for (lines.items) |inner| { + const vis_cols = displayWidthStyled(inner); + const right_pad = width -| pad_x -| vis_cols; + const pad_str = try a.alloc(u8, right_pad); + defer a.free(pad_str); + @memset(pad_str, ' '); + const composed = try std.fmt.allocPrint( + a, + "{s}{s}{s}{s}{s}{s}", + .{ bg.open(), fg.open(), indent, inner, pad_str, theme.reset }, + ); + try out.append(a, composed); + } + for (0..pad_y) |_| try out.append(a, try a.dupe(u8, blank_bg)); + // Bottom margin (no bg). + try out.append(a, try a.dupe(u8, "")); + try self.cache.store(out.items); + return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { @@ -1252,9 +1490,13 @@ pub const InputBox = struct { // Footer — persistent bottom line (plan §6) // =========================================================================== -/// The persistent bottom line. Renders model info and the latest context-window -/// token count, styled as dim chrome. `setModel(name)` sets the model info; -/// `setContextTokens(n)` updates the context readout. +/// The persistent bottom line. Renders model info and the latest +/// context-window token count, plus the running session token +/// total and the running session cost (when known), all styled as +/// dim chrome. `setModel(name)` sets the model info; +/// `setContextTokens(n)` updates the context readout; +/// `setSessionTokens(n)` / `setSessionCost(c)` update the +/// session-running readouts. pub const Footer = struct { alloc: std.mem.Allocator, cache: RenderCache, @@ -1265,6 +1507,17 @@ pub const Footer = struct { /// accumulated. Defined (plan §6) as /// `usage.input + usage.cache_read + usage.cache_write`. context_tokens: ?u64 = null, + /// Session-running sum of every reported `Usage`'s + /// `input + output + cache_read + cache_write`. Grew monotonically + /// across the session (including across model switches). Null until + /// the first turn reports usage. + session_tokens: ?u64 = null, + /// Session-running cost in micro-cents (the same unit `libpanto` + /// accumulates). Updated on each `message_complete` via + /// `panto.addCost`. Null while any turn has unknown pricing, or + /// until the first priced turn lands. The display layer formats + /// null as `"$unknown"`. + session_cost: ?u64 = null, pub fn init(alloc: std.mem.Allocator) Footer { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; @@ -1290,14 +1543,53 @@ pub const Footer = struct { self.cache.markDirty(); } + /// Set the session-running token total. The caller passes the + /// already-accumulated `sum of every turn's input + output + + /// cache_read + cache_write` (the display doesn't know about the + /// turn/category breakdown). Marked dirty so the footer repaints. + pub fn setSessionTokens(self: *Footer, tokens: ?u64) void { + if (self.session_tokens == tokens) return; + self.session_tokens = tokens; + self.cache.markDirty(); + } + + /// Set the session-running cost in micro-cents. `null` means + /// "unknown" (at least one turn had unknown pricing; the + /// per-pricing-field `null`s poison the total). The display + /// formats null as `"$unknown"`. + pub fn setSessionCost(self: *Footer, cost: ?u64) void { + if (self.session_cost == cost) return; + self.session_cost = cost; + self.cache.markDirty(); + } + /// Format the context-window element: e.g. "12.3k ctx" for large counts, /// "845 ctx" for small ones. "" (empty) when no usage reported yet, so the /// element is simply absent until the first `message_complete`. fn contextText(self: *const Footer, buf: []u8) []const u8 { const n = self.context_tokens orelse return ""; - if (n < 1000) return std.fmt.bufPrint(buf, "{d} ctx", .{n}) catch ""; - const k = @as(f64, @floatFromInt(n)) / 1000.0; - return std.fmt.bufPrint(buf, "{d:.1}k ctx", .{k}) catch ""; + return formatTokenShort(buf, n, " ctx"); + } + + /// Format the session token total: e.g. "1.2k tok", "12k tok", + /// "3.4M tok". "" (empty) when no usage reported yet. + fn sessionTokensText(self: *const Footer, buf: []u8) []const u8 { + const n = self.session_tokens orelse return ""; + return formatTokenShort(buf, n, " tok"); + } + + /// Format the session cost: "$1.23", "$0.06", or "$unknown" when + /// any priced component of any turn was null. The `$unknown` form + /// distinguishes "we have no price entry at all" from "the price + /// is exactly zero" (a known-zero that the user explicitly wrote + /// into models.toml). + fn sessionCostText(self: *const Footer, buf: []u8) []const u8 { + const c = self.session_cost orelse { + // Empty buf slot: we replace with the literal so the caller + // doesn't have to know about the special case. + return "$unknown"; + }; + return pricing_format.formatCostDollars(c, buf); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { @@ -1305,17 +1597,38 @@ pub const Footer = struct { const self: *Footer = @ptrCast(@alignCast(ptr)); const a = self.alloc; + // Format each optional segment in a small scratch buffer. The + // session cost buffer must be larger than the dollar form + // ("$12345678.90" = 14 chars) plus slack. var ctx_buf: [32]u8 = undefined; + var tok_buf: [32]u8 = undefined; + var cost_buf: [32]u8 = undefined; const ctx = self.contextText(&ctx_buf); + const tok = self.sessionTokensText(&tok_buf); + const cost = self.sessionCostText(&cost_buf); - // Build the PLAIN content: "<model> <ctx>" (each only when present). + // Build the PLAIN content as a fixed four-segment sequence, + // each separated by ` ` (three spaces). Segments that are + // empty (e.g. no usage yet) collapse cleanly because the + // leading and trailing separators are conditional on the next + // segment being non-empty. + // + // <model> <ctx> <session tokens> <session cost> var plain: std.ArrayList(u8) = .empty; defer plain.deinit(a); - if (self.model.items.len != 0) { - try plain.appendSlice(a, self.model.items); - if (ctx.len != 0) try plain.appendSlice(a, " "); + if (self.model.items.len != 0) try plain.appendSlice(a, self.model.items); + if (ctx.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, ctx); + } + if (tok.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, tok); + } + if (cost.len != 0) { + if (plain.items.len != 0) try plain.appendSlice(a, " "); + try plain.appendSlice(a, cost); } - if (ctx.len != 0) try plain.appendSlice(a, ctx); const vis = truncateToCols(plain.items, width); @@ -1960,6 +2273,36 @@ pub const CompactionSummary = struct { // ToolUse — one component owns the whole call + result (plan §6, P2) // =========================================================================== +/// Format the header line for a tool call, given the tool's name and +/// Render the framework-default tool header. The Zig core intentionally does +/// not special-case extension tool names here: extension-provided tools own +/// their display by registering `panto.ext.on("tool"/...)` handlers and +/// calling `event:setComponent(...)` for their own names. +/// +/// Allocation: caller-owned; the returned slice is from `buf`. +fn formatToolHeader(name: []const u8, input_json: []const u8, buf: []u8) []const u8 { + if (std.mem.eql(u8, name, "std.shell") or std.mem.eql(u8, name, "std__shell")) { + const command = extractJsonStringField(input_json, "command") orelse input_json; + return std.fmt.bufPrint(buf, "tool ({s}) {s}", .{ name, command }) catch buf[0..0]; + } + return std.fmt.bufPrint(buf, "tool ({s}) {s}", .{ name, input_json }) catch buf[0..0]; +} + +fn extractJsonStringField(json: []const u8, field: []const u8) ?[]const u8 { + var pat_buf: [128]u8 = undefined; + const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\":", .{field}) catch return null; + const start = std.mem.indexOf(u8, json, pat) orelse return null; + var i = start + pat.len; + while (i < json.len and (json[i] == ' ' or json[i] == '\t' or json[i] == '\n' or json[i] == '\r')) : (i += 1) {} + if (i >= json.len or json[i] != '"') return null; + i += 1; + const val_start = i; + while (i < json.len) : (i += 1) { + if (json[i] == '"' and (i == val_start or json[i - 1] != '\\')) return json[val_start..i]; + } + return null; +} + /// A single component that owns an entire tool call: its name, its streamed /// input (verbatim JSON args), and its result output. Render progression /// (plan §6 / P2 table): @@ -2070,12 +2413,7 @@ pub const ToolUse = struct { // null -> pending (dark blue-gray) // true -> success (dark green) // false -> error (dark red) - const bg = if (self.result_ok == null) - theme.default.fg(.tool_pending_bg) - else if (self.result_ok.?) - theme.default.fg(.tool_success_bg) - else - theme.default.fg(.tool_error_bg); + const bg = theme.default.fg(.tool_pending_bg); const header_fg = theme.default.fg(.tool_header); const dim_fg = theme.default.fg(.dim); const plain_fg = theme.default.fg(.assistant); @@ -2092,7 +2430,7 @@ pub const ToolUse = struct { bg: Style, width: usize, fn line(ctx: @This(), fg: Style, text: []const u8) ![]u8 { - const text_cols = displayWidth(text); + const text_cols = displayWidthStyled(text); const right_pad_n = ctx.width -| (1 + text_cols); // 1 = left indent col const rp = try ctx.a.alloc(u8, right_pad_n); defer ctx.a.free(rp); @@ -2127,16 +2465,17 @@ pub const ToolUse = struct { return cacheLines(&self.cache); } - // -- Header: `tool (<name>) <args json>` --------------------------- + // -- Header: generic framework-default form. Extension-specific + // tool renderers should claim their own calls via the event bus. + var header_buf: [1024]u8 = undefined; + const header_text = formatToolHeader(self.name.?.items, self.input.items, &header_buf); // Top padding row inside the block. try lines.append(a, try ctx.blank()); { - const header_plain = try std.fmt.allocPrint(a, "tool ({s}) {s}", .{ self.name.?.items, self.input.items }); - defer a.free(header_plain); var wrapped: std.ArrayList([]const u8) = .empty; defer wrapped.deinit(a); - try wrapParagraph(header_plain, inner_w, &wrapped, a); + try wrapParagraph(header_text, inner_w, &wrapped, a); for (wrapped.items) |wline| { const vis = truncateToCols(wline, inner_w); try lines.append(a, try ctx.line(header_fg, vis)); @@ -2146,7 +2485,7 @@ pub const ToolUse = struct { // Separator blank row inside the block. try lines.append(a, try ctx.blank()); - // -- Result region: `(…)` placeholder or output text --------------- + // -- Result region: `(…)` placeholder or output text. if (self.output == null) { const vis = truncateToCols("(\xe2\x80\xa6)", inner_w); try lines.append(a, try ctx.line(dim_fg, vis)); @@ -2166,7 +2505,7 @@ pub const ToolUse = struct { try lines.append(a, try ctx.line(dim_fg, vis)); } for (out_lines.items[start..]) |oline| { - const vis = truncateToCols(oline, inner_w); + const vis = truncateStyledToCols(oline, inner_w); try lines.append(a, try ctx.line(plain_fg, vis)); } } @@ -2791,9 +3130,10 @@ test "Footer: contextText formatting boundaries (0, 999, 1000 -> k)" { // Just below the k threshold stays verbatim. ft.setContextTokens(999); try testing.expectEqualStrings("999 ctx", ft.contextText(&buf)); - // Exactly 1000 crosses into the k suffix. + // Exactly 1000 crosses into the k suffix. The compact form + // strips trailing zeros, so "1.0k" becomes "1k". ft.setContextTokens(1000); - try testing.expectEqualStrings("1.0k ctx", ft.contextText(&buf)); + try testing.expectEqualStrings("1k ctx", ft.contextText(&buf)); } test "Footer: large context token counts format as k; latest wins" { @@ -2804,7 +3144,7 @@ test "Footer: large context token counts format as k; latest wins" { try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); // Overwritten (latest-wins), not accumulated. ft.setContextTokens(2000); - try testing.expectEqualStrings("2.0k ctx", ft.contextText(&buf)); + try testing.expectEqualStrings("2k ctx", ft.contextText(&buf)); } test "Footer: setContextTokens dirties; stable re-render is clean" { @@ -2819,6 +3159,89 @@ test "Footer: setContextTokens dirties; stable re-render is clean" { try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged()); } +test "Footer: session token accumulator: absent until set, then displayed" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + try ft.setModel("m"); + var buf: [32]u8 = undefined; + // No setSessionTokens call yet => " tok" is absent from the render. + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], " tok") == null); + } + ft.setSessionTokens(12345); + try testing.expectEqualStrings("12.3k tok", ft.sessionTokensText(&buf)); + // Rendered alongside the model. + { + const lines = try ft.comp().render(80, testing.allocator); + try testing.expect(std.mem.indexOf(u8, lines[0], "12.3k tok") != null); + } + // Setting the same value is a no-op (no spurious dirty). + ft.setSessionTokens(12345); + try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged()); +} + +test "Footer: session cost: null -> '$unknown'; known value formats" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + // Default is null => "$unknown". + try testing.expectEqualStrings("$unknown", ft.sessionCostText(&buf)); + // 1 dollar = 100_000_000 micro-cents. + ft.setSessionCost(100_000_000); + try testing.expectEqualStrings("$1.00", ft.sessionCostText(&buf)); + // 60 cents. + ft.setSessionCost(60_000_000); + try testing.expectEqualStrings("$0.60", ft.sessionCostText(&buf)); +} + +test "Footer: full render shows model + ctx + session tokens + cost" { + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + try ft.setModel("anthropic:haiku (high)"); + ft.setContextTokens(8_000); + ft.setSessionTokens(125_000); + ft.setSessionCost(6_000_000); + const lines = try ft.comp().render(120, testing.allocator); + // All four segments present, separated by three spaces, in order. + const got = lines[0]; + try testing.expect(std.mem.indexOf(u8, got, "anthropic:haiku (high)") != null); + try testing.expect(std.mem.indexOf(u8, got, "8k ctx") != null); + try testing.expect(std.mem.indexOf(u8, got, "125k tok") != null); + try testing.expect(std.mem.indexOf(u8, got, "$0.06") != null); +} + +test "Footer: setSessionCost null on a previously known cost poisons back" { + // Defensive: the App's recorder never voluntarily re-poisons + // (the poison rule is one-way), but the setter accepts null + // and the display flips back to "$unknown". A test pins this + // contract. + var ft = Footer.init(testing.allocator); + defer ft.deinit(); + var buf: [32]u8 = undefined; + ft.setSessionCost(6_000_000); + try testing.expectEqualStrings("$0.06", ft.sessionCostText(&buf)); + ft.setSessionCost(null); + try testing.expectEqualStrings("$unknown", ft.sessionCostText(&buf)); +} + +test "formatTokenShort: k and M boundaries, with and without a fractional" { + var buf: [16]u8 = undefined; + // Below the k threshold: raw integer. + try testing.expectEqualStrings("0", formatTokenShort(&buf, 0, "")); + try testing.expectEqualStrings("999", formatTokenShort(&buf, 999, "")); + // k threshold: trailing-zero fractional is stripped. + try testing.expectEqualStrings("1k", formatTokenShort(&buf, 1000, "")); + try testing.expectEqualStrings("1.2k", formatTokenShort(&buf, 1234, "")); + try testing.expectEqualStrings("12k", formatTokenShort(&buf, 12_000, "")); + try testing.expectEqualStrings("12.3k", formatTokenShort(&buf, 12_345, "")); + // M threshold. + try testing.expectEqualStrings("1M", formatTokenShort(&buf, 1_000_000, "")); + try testing.expectEqualStrings("1.5M", formatTokenShort(&buf, 1_500_000, "")); + // Suffix is appended. + try testing.expectEqualStrings("12k ctx", formatTokenShort(&buf, 12_000, " ctx")); +} + // -- Selector --------------------------------------------------------------- const sel_items = [_]SelectorItem{ @@ -2932,6 +3355,8 @@ test "components drive the real engine without a TTY" { defer footer.deinit(); try user.setText("hi there"); + // The assistant text is the streaming path: markdown renders all complete + // lines, and the trailing partial line is shown verbatim while it streams. try assistant.appendDelta("hello"); ib.setFocused(true); try ib.applyKey(charKey('q', "q")); @@ -2945,16 +3370,28 @@ test "components drive the real engine without a TTY" { try eng.render(); // first paint: must not error (width contract holds) const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "hi there") != null); + // The trailing partial line is shown immediately. try testing.expect(std.mem.indexOf(u8, out, "hello") != null); + // The closing newline commits the line; the next paint still shows it. + try assistant.appendDelta("\n"); + try eng.render(); + const out_after_newline = buf.written(); + try testing.expect(std.mem.indexOf(u8, out_after_newline, "hello") != null); // Cursor marker is consumed by the engine and recorded as a hint. try testing.expect(eng.cursor_hint != null); // Stream another delta -> only the assistant should re-render; the engine // stays on the differential path (no full clear after first paint). + // The trailing partial line is shown immediately, even before the closing + // newline arrives. try assistant.appendDelta(" world"); try footer.setModel("m2"); buf.clearRetainingCapacity(); try eng.render(); + const out_partial = buf.written(); + try testing.expect(std.mem.indexOf(u8, out_partial, "world") != null); + try assistant.appendDelta("\n"); + try eng.render(); const out2 = buf.written(); try testing.expect(std.mem.indexOf(u8, out2, "world") != null); } @@ -3036,18 +3473,19 @@ test "ToolUse: stage 1 renders tool (?) before the name resolves" { try testing.expect(found); } -test "ToolUse: stage 2 shows name + verbatim json + placeholder" { +test "ToolUse: stage 2 shows generic header + placeholder" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("read"); try t.appendInput("{\"path\":\"a\"}"); const lines = try t.comp().render(60, testing.allocator); - // margin+pad+header+sep+(\u2026)+pad+margin = 7 + // margin+pad+header+sep+placeholder+pad+margin = 7 try testing.expect(lines.len >= 7); - // Header is at lines[2] (top-margin + top-pad + header). + // The framework default does not special-case tool names. Extensions own + // their rendering by claiming their own tool events. try testing.expect(std.mem.indexOf(u8, lines[2], "tool (read) {\"path\":\"a\"}") != null); - // Placeholder (\u2026) is before bottom pad (lines[len-3]). - try testing.expect(std.mem.indexOf(u8, lines[lines.len - 3], "(…)") != null); + // Placeholder (U+2026) is before the bottom pad (lines[len-3]). + try testing.expect(std.mem.indexOf(u8, lines[lines.len - 3], "(\xe2\x80\xa6)") != null); for (lines) |l| try testing.expect(vw(l) <= 60); } diff --git a/src/tui_theme.zig b/src/tui_theme.zig index 9e7e565..038deb5 100644 --- a/src/tui_theme.zig +++ b/src/tui_theme.zig @@ -43,6 +43,14 @@ pub const StyleName = enum { err, /// Welcome banner accent (session start chrome). welcome, + /// Edit-diff `-` line prefix (slightly lighter red than `.err` + /// so the surrounding body text remains readable when the + /// prefix is the only colored byte on the line). + err_diff, + /// Edit-diff `+` line prefix (slightly lighter green than + /// `.tool_success_bg`; this is a FOREGROUND color used to + /// color the leading `+` byte in a diff, not a background). + add_diff, /// Thinking block body (dimmed, distinct entry so the taxonomy is honest /// even though it currently resolves to the same dim escape). thinking, @@ -123,6 +131,11 @@ fn styleFor(name: StyleName) Style { // Reverse video — the virtual cursor block. .cursor => .{ .open_seq = "\x1b[7m" }, .err => .{ .open_seq = "\x1b[31m" }, + // Diff -prefix: lighter red so the surrounding body text + // stays readable; only the leading byte is colored. + .err_diff => .{ .open_seq = "\x1b[38;2;240;100;100m" }, + // Diff +prefix: green, slightly muted. + .add_diff => .{ .open_seq = "\x1b[38;2;100;200;120m" }, // Welcome banner: cyan accent, matching the tool accent family. .welcome => .{ .open_seq = "\x1b[36m" }, // Thinking body: dimmed, like status chrome. |
