summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
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/tui_components.zig
parent8206642d3a1736aaf928a5b9a732e747f5294228 (diff)
display fixes: markdown rendering, tool-specific components
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig526
1 files changed, 482 insertions, 44 deletions
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);
}