diff options
Diffstat (limited to 'src/tui_components.zig')
| -rw-r--r-- | src/tui_components.zig | 526 |
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); } |
