//! Built-in P1 components for the TUI (plan §6). //! //! Each component satisfies the `Component` vtable (`tui_component.zig`) and //! implements the cache-derived dirty model exactly as `RenderCache` defines //! it: any state mutation calls `markDirty`/`markDirtyFrom` (drops the cache), //! and a successful `render` calls `cache.store(lines)` (diffs the new lines //! against the prior cache, records the lowest differing index, re-populates //! the cache, and marks it clean). `firstLineChanged` is therefore derived //! purely from cache state and never a hand-managed integer that can drift. //! //! Data-in / lines-out: each component takes STRUCTURED DATA IN via setters or //! delta-appenders and produces LINES OUT from `render(width, alloc)`. Every //! returned line's visible width is <= `width` (we TRUNCATE; the engine treats //! overflow as a hard error per plan §3.1). //! //! Render storage convention: a component renders into a transient list, calls //! `cache.store(lines)` (which dupes the bytes into cache-owned storage), then //! returns `cache.lines` re-typed as `[]const []const u8`. The returned slices //! are owned by the cache and stay valid until the next `render`/`invalidate` //! — satisfying the vtable's lifetime contract. const std = @import("std"); 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; const RenderCache = component.RenderCache; const CURSOR_MARKER = component.CURSOR_MARKER; const Style = theme.Style; const ansi_reset = theme.reset; const Key = key.Key; const KeyCode = key.KeyCode; // =========================================================================== // Shared helpers // =========================================================================== /// Terminal display width of a single Unicode codepoint: /// 0 — combining marks, variation selectors, ZWJ, zero-width characters /// 2 — wide East-Asian (CJK, Hangul, fullwidth) and most emoji (U+1Fxxx) /// 1 — everything else pub fn codepointWidth(cp: u21) usize { // Zero-width: combining marks (U+0300–U+036F), Mn/Cf ranges, ZWJ (U+200D), // variation selectors (U+FE00–U+FE0F, U+E0100–U+E01EF), etc. if (cp == 0x200D) return 0; // ZWJ if (cp >= 0x0300 and cp <= 0x036F) return 0; // combining diacriticals if (cp >= 0x0483 and cp <= 0x0489) return 0; // combining Cyrillic if (cp >= 0x0591 and cp <= 0x05BD) return 0; // Hebrew points if (cp >= 0x0610 and cp <= 0x061A) return 0; // Arabic extended if (cp >= 0x064B and cp <= 0x065F) return 0; // Arabic combining if (cp >= 0x1AB0 and cp <= 0x1AFF) return 0; // combining ext. A if (cp >= 0x1DC0 and cp <= 0x1DFF) return 0; // combining supplement if (cp >= 0x20D0 and cp <= 0x20FF) return 0; // combining for symbols if (cp >= 0xFE00 and cp <= 0xFE0F) return 0; // variation selectors if (cp >= 0xFE20 and cp <= 0xFE2F) return 0; // combining half marks if (cp >= 0xE0100 and cp <= 0xE01EF) return 0; // variation sel. supplement // Wide (2 columns): CJK unified/extension, Hangul, fullwidth forms, most emoji. if (cp >= 0x1100 and cp <= 0x115F) return 2; // Hangul Jamo if (cp >= 0x2E80 and cp <= 0x303E) return 2; // CJK radicals / Kangxi if (cp >= 0x3041 and cp <= 0x33BF) return 2; // Hiragana / Katakana / CJK compat if (cp >= 0x33FF and cp <= 0x33FF) return 2; if (cp >= 0x3400 and cp <= 0x4DBF) return 2; // CJK extension A if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; // CJK unified if (cp >= 0xA000 and cp <= 0xA4CF) return 2; // Yi if (cp >= 0xA960 and cp <= 0xA97F) return 2; // Hangul Jamo ext A if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; // Hangul syllables if (cp >= 0xD7B0 and cp <= 0xD7FF) return 2; // Hangul Jamo ext B if (cp >= 0xF900 and cp <= 0xFAFF) return 2; // CJK compat ideographs if (cp >= 0xFE10 and cp <= 0xFE1F) return 2; // vertical forms if (cp >= 0xFE30 and cp <= 0xFE4F) return 2; // CJK compat forms if (cp >= 0xFF01 and cp <= 0xFF60) return 2; // fullwidth / halfwidth if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; // fullwidth signs if (cp >= 0x1B000 and cp <= 0x1B0FF) return 2; // Kana supplement if (cp >= 0x1F004 and cp <= 0x1F004) return 2; // mahjong tile if (cp >= 0x1F0CF and cp <= 0x1F0CF) return 2; // playing card joker if (cp >= 0x1F200 and cp <= 0x1F2FF) return 2; // enclosed ideographic if (cp >= 0x1F300 and cp <= 0x1F64F) return 2; // misc symbols, emoticons if (cp >= 0x1F680 and cp <= 0x1F6FF) return 2; // transport / map if (cp >= 0x1F700 and cp <= 0x1F77F) return 2; // alchemical if (cp >= 0x1F780 and cp <= 0x1F7FF) return 2; // geometric shapes ext if (cp >= 0x1F800 and cp <= 0x1F8FF) return 2; // supplemental arrows C if (cp >= 0x1F900 and cp <= 0x1F9FF) return 2; // supplemental symbols if (cp >= 0x1FA00 and cp <= 0x1FA6F) return 2; // chess / other if (cp >= 0x1FA70 and cp <= 0x1FAFF) return 2; // symbols and pictographs ext A if (cp >= 0x20000 and cp <= 0x2A6DF) return 2; // CJK extension B if (cp >= 0x2A700 and cp <= 0x2CEAF) return 2; // CJK extensions C/D/E if (cp >= 0x2CEB0 and cp <= 0x2EBEF) return 2; // CJK extension F if (cp >= 0x2F800 and cp <= 0x2FA1F) return 2; // CJK compat supplement if (cp >= 0x30000 and cp <= 0x3134F) return 2; // CJK extension G return 1; } fn tabWidth(col: usize) usize { const rem = col % 8; return if (rem == 0) 8 else 8 - rem; } fn codepointWidthAt(cp: u21, col: usize) usize { return if (cp == '\t') tabWidth(col) else codepointWidth(cp); } /// Number of display columns occupied by `text`. `text` must be PLAIN (no /// escape sequences); components wrap on plain text and only add styling /// escapes afterward. pub fn displayWidth(text: []const u8) usize { var cols: usize = 0; var i: usize = 0; while (i < text.len) { 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 += codepointWidthAt(cp, cols); i += adv; } return cols; } /// Truncate `text` to at most `max_cols` display columns, returning a byte /// slice of `text` that ends on a codepoint boundary. Never splits a multibyte /// codepoint. pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 { var cols: usize = 0; var i: usize = 0; while (i < text.len) { 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 = codepointWidthAt(cp, cols); if (cols + w > max_cols) break; // adding this glyph would exceed limit i += adv; cols += w; } 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 (skipStyledEscape(text, i)) |next| { i = next; 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 += codepointWidthAt(cp, cols); i += adv; } return cols; } fn skipStyledEscape(text: []const u8, start_i: usize) ?usize { if (start_i + 1 >= text.len or text[start_i] != '\x1b') return null; switch (text[start_i + 1]) { '[' => { var i = start_i + 2; while (i < text.len) { const c = text[i]; i += 1; if (c >= '@' and c <= '~') return i; } return text.len; }, ']' => { var i = start_i + 2; while (i < text.len) { if (text[i] == 0x07) return i + 1; if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '\\') return i + 2; i += 1; } return text.len; }, else => return null, } } fn reapplyAfterReset(alloc: std.mem.Allocator, text: []const u8, bg_open: []const u8, fg_open: []const u8) ![]const u8 { const reset = theme.reset; var count: usize = 0; var pos: usize = 0; while (std.mem.indexOfPos(u8, text, pos, reset)) |idx| { count += 1; pos = idx + reset.len; } if (count == 0) return alloc.dupe(u8, text); const extra = count * (bg_open.len + fg_open.len); var out = try std.ArrayList(u8).initCapacity(alloc, text.len + extra); errdefer out.deinit(alloc); pos = 0; while (std.mem.indexOfPos(u8, text, pos, reset)) |idx| { try out.appendSlice(alloc, text[pos .. idx + reset.len]); try out.appendSlice(alloc, bg_open); try out.appendSlice(alloc, fg_open); pos = idx + reset.len; } try out.appendSlice(alloc, text[pos..]); return try out.toOwnedSlice(alloc); } /// 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 (skipStyledEscape(text, i)) |next| { i = next; 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 = codepointWidthAt(cp, cols); 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. /// An empty paragraph yields one empty line. Lines pushed to `out` are slices /// borrowed from `text` (no allocation of line bytes here; `out` only stores /// the slice headers). fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8), alloc: std.mem.Allocator) !void { if (width == 0) { try out.append(alloc, ""); return; } if (text.len == 0) { try out.append(alloc, ""); return; } // Greedy word-wrap. We accumulate a line by byte range [line_start, i); on // overflow we break at the last space that fits, or hard-split a word that // is wider than `width`. var line_start: usize = 0; var line_cols: usize = 0; var last_break: ?usize = null; // byte index of the last space on this line var i: usize = 0; while (i < text.len) { 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 cp_w = codepointWidthAt(cp, line_cols); const is_space = adv == 1 and text[i] == ' '; if (is_space and line_cols == width) { // The overflowing glyph is the inter-word space itself: break here // and consume the space (standard word-wrap discards it) so the // current word group fills the line exactly. try out.append(alloc, text[line_start..i]); line_start = i + adv; last_break = null; line_cols = 0; i += adv; continue; } if (line_cols + cp_w > width) { // Adding this glyph would overflow; break the line first. if (last_break) |brk| { // Break at the last space: emit up to (not including) it, and // start the next line just after it. try out.append(alloc, text[line_start..brk]); line_start = brk + 1; last_break = null; line_cols = displayWidth(text[line_start..i]); } else { // No space on this line: hard-split before the current glyph. try out.append(alloc, text[line_start..i]); line_start = i; line_cols = 0; } } if (is_space) last_break = i; line_cols += cp_w; i += adv; } // Flush the final line (always emit, even if empty/trailing fragment). try out.append(alloc, text[line_start..]); } /// Append `text` to `out` with terminal escape sequences removed. Tool output /// frequently carries SGR colour codes and other ANSI control sequences; the /// render component styles its own background, so raw escapes corrupt the box. /// Recognised forms: /// - CSI: ESC `[` ... final byte in 0x40..0x7E (e.g. SGR colours) /// - OSC: ESC `]` ... terminated by BEL (0x07) or ST (ESC `\`) /// - Other escapes: ESC, optional intermediate bytes (0x20..0x2F), final byte /// (e.g. charset select `ESC ( B`) /// A bare trailing ESC (truncated sequence) is dropped. All non-escape bytes, /// including newlines and UTF-8, pass through unchanged. fn stripAnsi(text: []const u8, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void { var i: usize = 0; while (i < text.len) { const c = text[i]; if (c != 0x1B) { try out.append(alloc, c); i += 1; continue; } // ESC seen. Look at the next byte to classify the sequence. if (i + 1 >= text.len) break; // bare trailing ESC: drop it. const kind = text[i + 1]; if (kind == '[') { // CSI: consume until a final byte in 0x40..0x7E (inclusive). var j = i + 2; while (j < text.len and !(text[j] >= 0x40 and text[j] <= 0x7E)) j += 1; i = if (j < text.len) j + 1 else text.len; } else if (kind == ']') { // OSC: consume until BEL or ST (ESC `\`). var j = i + 2; while (j < text.len) { if (text[j] == 0x07) { j += 1; break; } if (text[j] == 0x1B and j + 1 < text.len and text[j + 1] == '\\') { j += 2; break; } j += 1; } i = j; } else { // Other escapes (e.g. charset select `ESC ( B`): consume any // intermediate bytes (0x20..0x2F) then one final byte. var j = i + 1; while (j < text.len and text[j] >= 0x20 and text[j] <= 0x2F) j += 1; i = if (j < text.len) j + 1 else text.len; } } } /// Split `buffer` on newlines into paragraphs and wrap each to `width`, /// appending all produced lines to `out`. A trailing newline produces a final /// empty line (so a freshly-typed "\n" shows a blank row). An empty buffer /// 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; // 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()) |line| { try wrapParagraph(line, width, out, alloc); } } /// Build the cache-owned line set for a styled text block: each wrapped plain /// line is wrapped in `style.open()`/`style.close()` and stored via the cache. /// Returns the cache's owned lines re-typed for the vtable. /// /// `width` bounds the *visible* width: the plain text is truncated to `width` /// columns BEFORE styling escapes are added (escapes are zero visible width). fn renderStyledLines( cache: *RenderCache, buffer: []const u8, style: Style, width: usize, alloc: std.mem.Allocator, ) ![]const []const u8 { // 1. Wrap plain text into borrowed slices. var plain: std.ArrayList([]const u8) = .empty; defer plain.deinit(alloc); try wrapBuffer(buffer, width, &plain, alloc); // 2. Style each line into a transient owned buffer. var styled: std.ArrayList([]const u8) = .empty; defer { for (styled.items) |s| alloc.free(s); styled.deinit(alloc); } for (plain.items) |line| { // Defensive truncate (wrap already bounds it, but a hard contract). const vis = truncateToCols(line, width); const composed = try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ style.open(), vis, style.close() }); try styled.append(alloc, composed); } // 3. Commit to the cache (dupes), then return the cache's owned copy. try cache.store(styled.items); return cacheLines(cache); } /// Render a full-width background block with surrounding whitespace. /// /// Layout (what gets pushed to `out`): /// - 1 blank margin line (no bg — uses the terminal's default background) /// - N content lines, each right-padded to `width` columns so the `bg_style` /// fill colour extends to the right edge of the terminal /// - 1 blank margin line /// /// `bg_style` sets the background colour; `fg_style` sets the text colour. /// They are stacked as `bg_open ++ fg_open ++ text ++ reset`. Both can be /// `.is_plain = true` (no-op) if only one layer is needed. /// /// The content text is word-wrapped to `width - 2*pad_x` columns and each /// line is indented by `pad_x` spaces on the left, then right-padded with /// spaces to fill the full `width`. `pad_x = 1` matches pi's style. /// /// `pad_y` adds that many bg-coloured blank lines inside the block above and /// below the text content. Use `pad_y = 1` for user messages. fn renderBlock( out: *std.ArrayList([]const u8), buffer: []const u8, bg_style: Style, fg_style: Style, width: usize, pad_x: usize, pad_y: usize, alloc: std.mem.Allocator, ) !void { // Blank margin above (no bg). try out.append(alloc, try alloc.dupe(u8, "")); // Wrap the inner text to (width - 2*pad_x) columns, with a floor of 1. const inner_w = if (width > 2 * pad_x) width - 2 * pad_x else 1; var plain: std.ArrayList([]const u8) = .empty; defer plain.deinit(alloc); if (buffer.len == 0) { try plain.append(alloc, ""); } else { try wrapBuffer(buffer, inner_w, &plain, alloc); } // A full-width bg-only line (for vertical padding inside the block). const bg_blank_spaces = try alloc.alloc(u8, width); defer alloc.free(bg_blank_spaces); @memset(bg_blank_spaces, ' '); const bg_blank = try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ bg_style.open(), bg_blank_spaces, ansi_reset, }); defer alloc.free(bg_blank); // Top vertical padding (inside bg). for (0..pad_y) |_| try out.append(alloc, try alloc.dupe(u8, bg_blank)); // Build the left-indent string once. const indent = try alloc.alloc(u8, pad_x); defer alloc.free(indent); @memset(indent, ' '); for (plain.items) |line| { const vis = truncateToCols(line, inner_w); const text_cols = displayWidth(vis); // Right-pad so bg colour fills to the right edge. // Saturating subtract guards against wide-char overshoot edge cases. const right_pad = width -| pad_x -| text_cols; const right_spaces = try alloc.alloc(u8, right_pad); defer alloc.free(right_spaces); @memset(right_spaces, ' '); const composed = try std.fmt.allocPrint(alloc, "{s}{s}{s}{s}{s}{s}", .{ bg_style.open(), fg_style.open(), indent, vis, right_spaces, ansi_reset, }); try out.append(alloc, composed); } // Bottom vertical padding (inside bg). for (0..pad_y) |_| try out.append(alloc, try alloc.dupe(u8, bg_blank)); // Blank margin below. try out.append(alloc, try alloc.dupe(u8, "")); } /// Thin wrapper around `renderBlock` that also stores the result in `cache` /// and returns the cache's owned lines. The transient `out` lines are freed /// here after the cache dupes them. fn renderBlockCached( cache: *RenderCache, buffer: []const u8, bg_style: Style, fg_style: Style, width: usize, pad_x: usize, pad_y: usize, alloc: std.mem.Allocator, ) ![]const []const u8 { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| alloc.free(l); out.deinit(alloc); } try renderBlock(&out, buffer, bg_style, fg_style, width, pad_x, pad_y, alloc); try cache.store(out.items); return cacheLines(cache); } /// Re-type the cache's owned `[][]u8` lines as `[]const []const u8` for the /// vtable return. The cache guarantees these outlive the call until the next /// render/invalidate. fn cacheLines(cache: *RenderCache) []const []const u8 { const owned = cache.lines orelse return &.{}; 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) // =========================================================================== /// Accumulates assistant content deltas into an internal buffer and renders /// the wrapped text with the theme's assistant style. /// /// Streaming-tail dirty model (plan §3.3): a delta is appended to the buffer /// and the cache is marked dirty; the engine then requests a render. Because /// appended text only changes the LAST wrapped line(s) and leaves earlier /// wrapped lines byte-identical, `RenderCache.store`'s diff naturally reports /// `firstLineChanged` near the TAIL, not line 0 — the cut stays near the end /// during streaming. (There is no per-delta render method on the interface; /// the delta just mutates state + dirties, per plan §8.) /// /// Markdown hosting (plan §8, DEFERRED): the buffer/render are structured so a /// later pass can cache finished blocks and only re-render the last open block. /// For P1 this is plain text + word wrap; the tail-near firstLineChanged /// property already holds via the cache diff, so the markdown upgrade slots in /// without changing the dirty model. 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 { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *AssistantText) void { self.buffer.deinit(self.alloc); self.cache.deinit(); } /// Append a streaming content delta. Mutates the buffer and marks the cache /// dirty (the engine will requestRender). The cache diff keeps /// 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)); 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 { const self: *AssistantText = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *AssistantText = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *AssistantText) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // UserText — submitted user message (plan §6) // =========================================================================== /// A submitted user message, rendered with the theme's user style. Static once /// set; `setText` replaces it and marks dirty. pub const UserText = struct { alloc: std.mem.Allocator, buffer: std.ArrayList(u8) = .empty, cache: RenderCache, pub fn init(alloc: std.mem.Allocator) UserText { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *UserText) void { self.buffer.deinit(self.alloc); self.cache.deinit(); } /// Set the (static) message text. Marks dirty. pub fn setText(self: *UserText, text: []const u8) !void { self.buffer.clearRetainingCapacity(); try self.buffer.appendSlice(self.alloc, text); self.cache.markDirty(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *UserText = @ptrCast(@alignCast(ptr)); 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, ' '); // Markdown inline styles currently close with a full SGR reset. // Inside the user-message block that reset must be immediately // followed by the block's bg+fg again, otherwise the faint user // background disappears for the rest of the visual line (including // right padding) after `code`, *em*, **strong**, etc. const inner_user = try reapplyAfterReset(a, inner, bg.open(), fg.open()); defer a.free(inner_user); const composed = try std.fmt.allocPrint( a, "{s}{s}{s}{s}{s}{s}", .{ bg.open(), fg.open(), indent, inner_user, 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 { const self: *UserText = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *UserText = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *UserText) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // InputBox — editable single-row+ input (plan §6, §3.5) // =========================================================================== /// A `Focusable` editor. Single row by default; ENTER submits, SHIFT+ENTER /// inserts a newline (grows one row per line). Growth is UNBOUNDED in P1 (the /// cap + scroll-window is deferred to P2). /// /// Editing (raw keys via `handleInput`, decoded by `tui_input`): /// - printable chars (UTF-8) insert at the cursor /// - backspace deletes the codepoint before the cursor /// - delete removes the codepoint at the cursor /// - left/right move by one codepoint; home/end jump to start/end of the /// current visual buffer (P1: whole buffer, not per-line) /// - ENTER submits the whole buffer (see "Submit mechanism") /// - SHIFT+ENTER inserts a '\n' /// /// Cursor (plan §3.5): the box draws its OWN cursor as a reverse-video block /// (theme `.cursor` style) over the glyph at the cursor position. When focused /// it also emits `CURSOR_MARKER` (zero visible width) at the cursor location in /// its render output, so the engine can later position the hardware cursor. /// /// SHIFT+ENTER limitation: on terminals without the Kitty protocol, Enter and /// Shift+Enter send identical bytes (`\r`); the decoder cannot distinguish them /// and both arrive as `.enter` with no shift modifier, so only plain submit is /// possible there. When Kitty IS active, Shift+Enter arrives as CSI-u /// (`\x1b[13;2u`) with `mods.shift` set and this box inserts a newline. The /// logic works wherever the distinction is available. /// /// Submit mechanism: a POLLABLE buffer. On ENTER the current editor contents /// are moved into `submitted` and the editor is cleared. The app calls /// `takeSubmitted()` once per frame; it returns the submitted bytes (owned by /// the box, valid until the next `takeSubmitted`/edit) and clears the pending /// flag, or null if nothing was submitted. This avoids callback re-entrancy /// into the render loop. pub const InputBox = struct { alloc: std.mem.Allocator, focusable: Focusable = .{}, /// Editor contents (may contain '\n' for multi-line input). UTF-8. text: std.ArrayList(u8) = .empty, /// Cursor position as a BYTE offset into `text` (always on a codepoint /// boundary). cursor: usize = 0, /// Pending submitted line, owned by the box. Valid until the next submit /// or `takeSubmitted`. submitted: std.ArrayList(u8) = .empty, has_submitted: bool = false, /// Submitted-input history (oldest first, owned copies) for Up/Down /// recall. In-memory per-run only. history: std.ArrayList([]u8) = .empty, /// Current position while browsing history; null = editing the live buffer. hist_index: ?usize = null, /// The live buffer, stashed when history browsing begins so Down past the /// newest entry restores it. hist_stash: std.ArrayList(u8) = .empty, /// Maximum number of VISUAL rows the box renders at once (plan §6 / P2). /// When the wrapped/`\n`-split buffer exceeds this many rows, the box /// renders only a `line_cap`-tall SCROLL-WINDOW that follows the cursor /// (so the cursor row stays visible). The default is 8. A single-row /// buffer still renders one row — the cap is a ceiling, not a floor, so /// the existing "single-row default, grow one row per line" behavior is /// preserved up to the cap. line_cap: usize = default_line_cap, cache: RenderCache, /// Default visual-row cap (plan §6, P2): show at most the last 8 contiguous /// lines once the buffer grows past the window. pub const default_line_cap: usize = 8; pub fn init(alloc: std.mem.Allocator) InputBox { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *InputBox) void { self.text.deinit(self.alloc); self.submitted.deinit(self.alloc); for (self.history.items) |h| self.alloc.free(h); self.history.deinit(self.alloc); self.hist_stash.deinit(self.alloc); self.cache.deinit(); } // -- focus ------------------------------------------------------------- /// Set focus. Re-dirties because the cursor block + marker only render when /// focused, so focus changes alter the output. pub fn setFocused(self: *InputBox, value: bool) void { if (self.focusable.focused != value) { self.focusable.setFocused(value); self.cache.markDirty(); } } pub fn isFocused(self: *const InputBox) bool { return self.focusable.focused; } // -- submit polling ---------------------------------------------------- /// Poll the submitted line. Returns the bytes (box-owned) and clears the /// pending flag, or null if nothing was submitted since the last poll. pub fn takeSubmitted(self: *InputBox) ?[]const u8 { if (!self.has_submitted) return null; self.has_submitted = false; return self.submitted.items; } // -- buffer access (for the Ctrl+G $EDITOR round-trip) ----------------- /// The current editor contents (box-owned; valid until the next edit). /// Used by the app's Ctrl+G handler to seed the external-editor tempfile. pub fn buffer(self: *const InputBox) []const u8 { return self.text.items; } /// Replace the whole editor buffer (e.g. with text edited in `$EDITOR`). /// Places the cursor at the end and marks dirty. Ends any Up/Down history /// browse: the buffer no longer shows the entry `hist_index` points at /// (historyPrev/Next re-set the index after calling this). pub fn setBuffer(self: *InputBox, bytes: []const u8) !void { self.text.clearRetainingCapacity(); try self.text.appendSlice(self.alloc, bytes); self.cursor = self.text.items.len; self.hist_index = null; self.cache.markDirty(); } // -- editing primitives (also directly unit-testable) ------------------ /// Insert text at the cursor (also the app's Tab path-completion hook). pub fn insertText(self: *InputBox, bytes: []const u8) !void { try self.text.insertSlice(self.alloc, self.cursor, bytes); self.cursor += bytes.len; self.cache.markDirty(); } fn backspace(self: *InputBox) void { if (self.cursor == 0) return; const start = self.prevBoundary(self.cursor); const removed = self.cursor - start; std.mem.copyForwards(u8, self.text.items[start..], self.text.items[self.cursor..]); self.text.items.len -= removed; self.cursor = start; self.cache.markDirty(); } fn deleteForward(self: *InputBox) void { if (self.cursor >= self.text.items.len) return; const next = self.nextBoundary(self.cursor); const removed = next - self.cursor; std.mem.copyForwards(u8, self.text.items[self.cursor..], self.text.items[next..]); self.text.items.len -= removed; self.cache.markDirty(); } fn moveLeft(self: *InputBox) void { if (self.cursor == 0) return; self.cursor = self.prevBoundary(self.cursor); self.cache.markDirty(); } fn moveRight(self: *InputBox) void { if (self.cursor >= self.text.items.len) return; self.cursor = self.nextBoundary(self.cursor); self.cache.markDirty(); } fn moveHome(self: *InputBox) void { if (self.cursor == 0) return; self.cursor = 0; self.cache.markDirty(); } fn moveEnd(self: *InputBox) void { if (self.cursor == self.text.items.len) return; self.cursor = self.text.items.len; self.cache.markDirty(); } /// Byte index of the start of the current LOGICAL line (the byte just after /// the previous '\n', or 0). "Logical line" = a run delimited by '\n' in /// the buffer, independent of visual wrapping. fn lineStart(self: *const InputBox, at: usize) usize { if (at == 0) return 0; if (std.mem.lastIndexOfScalar(u8, self.text.items[0..at], '\n')) |nl| return nl + 1; return 0; } /// Byte index of the end of the current LOGICAL line (the next '\n' at or /// after `at`, or the buffer end). fn lineEnd(self: *const InputBox, at: usize) usize { if (std.mem.indexOfScalarPos(u8, self.text.items, at, '\n')) |nl| return nl; return self.text.items.len; } /// Move the cursor to the start of the current logical line (Ctrl+A). fn moveLineStart(self: *InputBox) void { const dest = self.lineStart(self.cursor); if (dest == self.cursor) return; self.cursor = dest; self.cache.markDirty(); } /// Move the cursor to the end of the current logical line (Ctrl+E). fn moveLineEnd(self: *InputBox) void { const dest = self.lineEnd(self.cursor); if (dest == self.cursor) return; self.cursor = dest; self.cache.markDirty(); } /// Whether the codepoint starting at byte `i` is "word" whitespace for /// word-motion. We treat ASCII spaces, tabs, and newlines as separators. fn isWordSep(self: *const InputBox, i: usize) bool { const b = self.text.items[i]; return b == ' ' or b == '\t' or b == '\n'; } /// Byte index one word to the LEFT of `from` (standard word-motion: skip a /// run of separators, then a run of non-separators). Returns 0 at the /// start. Operates on codepoint boundaries. fn prevWord(self: *const InputBox, from: usize) usize { var i = from; // Skip separators immediately to the left. while (i > 0) { const p = self.prevBoundary(i); if (!self.isWordSep(p)) break; i = p; } // Skip the word (non-separators) to the left. while (i > 0) { const p = self.prevBoundary(i); if (self.isWordSep(p)) break; i = p; } return i; } /// Byte index one word to the RIGHT of `from` (skip a run of non-separators, /// then a run of separators). Returns the buffer end at the end. Operates on /// codepoint boundaries. fn nextWord(self: *const InputBox, from: usize) usize { var i = from; const len = self.text.items.len; // Skip the word (non-separators) to the right. while (i < len and !self.isWordSep(i)) i = self.nextBoundary(i); // Skip trailing separators. while (i < len and self.isWordSep(i)) i = self.nextBoundary(i); return i; } /// Move one word left (Alt+Left / Ctrl+Left). fn moveWordLeft(self: *InputBox) void { if (self.cursor == 0) return; self.cursor = self.prevWord(self.cursor); self.cache.markDirty(); } /// Move one word right (Alt+Right / Ctrl+Right). fn moveWordRight(self: *InputBox) void { if (self.cursor >= self.text.items.len) return; self.cursor = self.nextWord(self.cursor); self.cache.markDirty(); } /// Delete from the cursor back to the start of the current logical line /// (Ctrl+U). A PLAIN delete — no kill-ring / yank buffer is kept. fn deleteToLineStart(self: *InputBox) void { const start = self.lineStart(self.cursor); if (start == self.cursor) return; const removed = self.cursor - start; std.mem.copyForwards(u8, self.text.items[start..], self.text.items[self.cursor..]); self.text.items.len -= removed; self.cursor = start; self.cache.markDirty(); } /// Delete the previous word (Ctrl+W). A PLAIN delete — no kill-ring. fn deletePrevWord(self: *InputBox) void { if (self.cursor == 0) return; const start = self.prevWord(self.cursor); if (start == self.cursor) return; const removed = self.cursor - start; std.mem.copyForwards(u8, self.text.items[start..], self.text.items[self.cursor..]); self.text.items.len -= removed; self.cursor = start; self.cache.markDirty(); } fn submit(self: *InputBox) !void { self.submitted.clearRetainingCapacity(); try self.submitted.appendSlice(self.alloc, self.text.items); self.has_submitted = true; // Record for Up/Down recall (skip empty and consecutive duplicates). const line = self.text.items; const last = if (self.history.items.len > 0) self.history.items[self.history.items.len - 1] else ""; if (line.len != 0 and !std.mem.eql(u8, last, line)) { try self.history.append(self.alloc, try self.alloc.dupe(u8, line)); } self.hist_index = null; self.text.clearRetainingCapacity(); self.cursor = 0; self.cache.markDirty(); } // -- input history (Up/Down recall) ------------------------------------- /// Up at buffer-top: step to the previous history entry, stashing the live /// buffer on first entry. fn historyPrev(self: *InputBox) !void { if (self.history.items.len == 0) return; var target: usize = undefined; if (self.hist_index) |i| { if (i == 0) return; target = i - 1; } else { self.hist_stash.clearRetainingCapacity(); try self.hist_stash.appendSlice(self.alloc, self.text.items); target = self.history.items.len - 1; } try self.setBuffer(self.history.items[target]); // resets hist_index self.hist_index = target; } /// Down at buffer-bottom: step to the next history entry; past the newest, /// restore the stashed live buffer. fn historyNext(self: *InputBox) !void { const i = self.hist_index orelse return; if (i + 1 < self.history.items.len) { try self.setBuffer(self.history.items[i + 1]); // resets hist_index self.hist_index = i + 1; } else { try self.setBuffer(self.hist_stash.items); // resets hist_index } } /// Move the cursor one '\n'-line up, keeping the byte column (clamped to /// the target line, snapped back to a codepoint boundary). fn moveLineUp(self: *InputBox) void { const cur_start = self.lineStart(self.cursor); if (cur_start == 0) return; const col = self.cursor - cur_start; const prev_start = self.lineStart(cur_start - 1); self.cursor = @min(prev_start + col, cur_start - 1); self.snapBack(); self.cache.markDirty(); } /// Move the cursor one '\n'-line down (same column policy as moveLineUp). fn moveLineDown(self: *InputBox) void { const nl = self.lineEnd(self.cursor); if (nl == self.text.items.len) return; const col = self.cursor - self.lineStart(self.cursor); self.cursor = @min(nl + 1 + col, self.lineEnd(nl + 1)); self.snapBack(); self.cache.markDirty(); } /// Snap the cursor back off any UTF-8 continuation byte it landed on. fn snapBack(self: *InputBox) void { while (self.cursor > 0 and self.cursor < self.text.items.len and isContinuation(self.text.items[self.cursor])) self.cursor -= 1; } /// Byte index of the codepoint boundary before `i` (i > 0). fn prevBoundary(self: *const InputBox, i: usize) usize { var j = i - 1; while (j > 0 and isContinuation(self.text.items[j])) : (j -= 1) {} return j; } /// Byte index of the next codepoint boundary after `i` (i < len). fn nextBoundary(self: *const InputBox, i: usize) usize { const seq_len = std.unicode.utf8ByteSequenceLength(self.text.items[i]) catch 1; return @min(i + seq_len, self.text.items.len); } fn isContinuation(b: u8) bool { return (b & 0xc0) == 0x80; } // -- input handling ---------------------------------------------------- /// Apply one decoded key. Split out so tests can drive editing without raw /// byte sequences. pub fn applyKey(self: *InputBox, k: Key) !void { if (k.event == .release) return; switch (k.code) { .char => { // Ctrl/Alt chord bindings (standard editing shortcuts). These // are handled BEFORE the printable-insert path, which rejects // modified chars. NONE of these keep a kill-ring / yank buffer // — they are plain moves/deletes (plan P2: no kill-ring/undo). if (k.mods.ctrl and !k.mods.alt and !k.mods.super) { switch (std.ascii.toLower(@intCast(k.code.char & 0x7f))) { 'u' => { self.deleteToLineStart(); // delete to line start return; }, 'w' => { self.deletePrevWord(); // delete previous word return; }, 'a' => { self.moveLineStart(); // start of line return; }, 'e' => { self.moveLineEnd(); // end of line return; }, else => return, // other ctrl chords: ignore (Ctrl+G // handled at the app level, never reaches the box). } } // Alt-chord word bindings. Many terminals (notably Ghostty // and macOS terminals) send Alt+Left/Right as the classic // readline `ESC b` / `ESC f` rather than a modified arrow CSI, // so they arrive here as alt+b / alt+f char keys. Map them to // the same word-motion as Alt/Ctrl+Arrow so word navigation // works regardless of which form the terminal emits. if (k.mods.alt and !k.mods.ctrl and !k.mods.super) { switch (std.ascii.toLower(@intCast(k.code.char & 0x7f))) { 'b' => { self.moveWordLeft(); return; }, 'f' => { self.moveWordRight(); return; }, else => return, // other alt chords: ignore (not text) } } if (k.mods.alt or k.mods.super) return; // not a printable insert if (k.text) |t| { try self.insertText(t); } else { // Encode the codepoint ourselves when no text was carried. var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(k.code.char, &buf) catch return; try self.insertText(buf[0..n]); } }, .enter => { if (k.mods.shift) { try self.insertText("\n"); // shift+enter newline } else { try self.submit(); } }, // Alt/Ctrl+Backspace deletes the previous word (readline // convention); plain Backspace deletes one codepoint. .backspace => if (k.mods.alt or k.mods.ctrl) self.deletePrevWord() else self.backspace(), .delete => self.deleteForward(), // Word-motion: Alt+Left/Right (xterm/kitty) and Ctrl+Left/Right // (many terminals send `1;5D`/`1;5C`). Plain Left/Right move by one // codepoint. .left => if (k.mods.alt or k.mods.ctrl) self.moveWordLeft() else self.moveLeft(), .right => if (k.mods.alt or k.mods.ctrl) self.moveWordRight() else self.moveRight(), .home => self.moveHome(), .end => self.moveEnd(), // Up/Down: line motion inside a multiline buffer; history recall // at the buffer's top/bottom. .up => if (self.lineStart(self.cursor) == 0) try self.historyPrev() else self.moveLineUp(), .down => if (self.lineEnd(self.cursor) == self.text.items.len) try self.historyNext() else self.moveLineDown(), else => {}, // tab, fkeys: ignored } } fn handleInputImpl(ptr: *anyopaque, data: []const u8) void { const self: *InputBox = @ptrCast(@alignCast(ptr)); var off: usize = 0; while (off < data.len) { const step = input.decodeOne(data[off..]) orelse break; // partial tail: drop in P1 off += step.consumed; switch (step.decoded) { .key => |k| self.applyKey(k) catch return, .paste => |p| self.insertText(p) catch return, // Negotiation replies are consumed by the app before input is // routed here; ignore defensively if one slips through. .negotiation => {}, } } } // -- render ------------------------------------------------------------ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *InputBox = @ptrCast(@alignCast(ptr)); return self.renderLines(width); } /// Render the editor: split on '\n' into visual rows, place the styled /// cursor block + CURSOR_MARKER at the cursor row/column when focused. /// Truncates each row to `width` columns. /// /// Scroll-window (plan §6, P2): when the buffer produces more than /// `line_cap` rows, only a `line_cap`-tall window is rendered. The window /// FOLLOWS the cursor — it always contains the cursor's row — and defaults /// to the LAST `line_cap` rows (so a freshly grown buffer shows its tail, /// where the cursor usually is). A single-row buffer is unaffected: the cap /// is a ceiling, not a floor. fn renderLines(self: *InputBox, width: usize) ![]const []const u8 { const a = self.alloc; var rows: std.ArrayList([]const u8) = .empty; defer { for (rows.items) |r| a.free(r); rows.deinit(a); } const cursor_style = theme.default.fg(.cursor); // Locate the cursor's (row, byte-col-in-row). const focused = self.focusable.focused; // Walk logical lines, but render HARD-WRAPPED visual rows. Wrapping is // purely visual: no '\n' bytes are inserted into the editor buffer. // `cursor_row` records which produced visual row carries the cursor // block, so the scroll-window below can keep it visible. var line_byte_start: usize = 0; var produced_any = false; var cursor_row: usize = 0; var it = std.mem.splitScalar(u8, self.text.items, '\n'); while (it.next()) |line| { const line_start = line_byte_start; const line_end = line_start + line.len; const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and // At an explicit '\n' boundary, the cursor belongs to the next // logical line's start unless this is the final line. (self.cursor < line_end or it.peek() == null); const first_row = rows.items.len; try self.appendWrappedRows(line, line_start, cursor_in_line, cursor_style, width, focused, &rows, &cursor_row); produced_any = produced_any or rows.items.len > first_row; line_byte_start = line_end + 1; // skip the '\n' } if (!produced_any) { // Empty buffer: a single (possibly cursor-bearing) row. const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused); if (focused) cursor_row = 0; try rows.append(a, row); } // Apply the scroll-window: store at most `line_cap` rows, the window // defaulting to the tail and sliding up to keep the focused cursor row // visible. When unfocused there is no live cursor, so we never slide // up — the tail window stands. const window = self.scrollWindow(rows.items.len, if (focused) cursor_row else null); // Wrap the visible rows in dim horizontal rules so the input field // stands off from the transcript above and the footer below. The rule // spans the full width with box-drawing dashes. const rule = try self.horizontalRule(width); defer a.free(rule); var framed: std.ArrayList([]const u8) = .empty; defer framed.deinit(a); try framed.ensureTotalCapacity(a, window.end - window.start + 2); framed.appendAssumeCapacity(rule); for (rows.items[window.start..window.end]) |r| framed.appendAssumeCapacity(r); framed.appendAssumeCapacity(rule); try self.cache.store(framed.items); return cacheLines(&self.cache); } /// Append the hard-wrapped visual rows for one logical line. The slices are /// rendered immediately into owned row bytes; the editor buffer itself is /// unchanged. Cursor placement follows terminal wrapping semantics: a /// cursor exactly at a wrap boundary appears at column 0 of the next visual /// row, not at the end of the previous one. fn appendWrappedRows( self: *InputBox, line: []const u8, line_start: usize, cursor_in_line: bool, cursor_style: Style, width: usize, focused: bool, rows: *std.ArrayList([]const u8), cursor_row: *usize, ) !void { const a = self.alloc; if (width == 0) { const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused); if (cursor_in_line) cursor_row.* = rows.items.len; try rows.append(a, row); return; } if (line.len == 0) { const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused); if (cursor_in_line) cursor_row.* = rows.items.len; try rows.append(a, row); return; } var i: usize = 0; while (i < line.len) { const chunk_start = i; var cols: usize = 0; while (i < line.len) { const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; const adv = @min(seq_len, line.len - i); const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?'; const w = codepointWidthAt(cp, cols); if (cols > 0 and cols + w > width) break; i += adv; cols += w; if (cols >= width) break; } if (i == chunk_start) i = self.nextBoundary(line_start + i) - line_start; // defensive progress const chunk = line[chunk_start..i]; const abs_start = line_start + chunk_start; const abs_end = line_start + i; var cursor_col: ?usize = null; if (cursor_in_line) { if (self.cursor >= abs_start and self.cursor < abs_end) { cursor_col = self.cursor - abs_start; } else if (i == line.len and self.cursor == abs_end) { cursor_col = chunk.len; } } if (cursor_col != null) cursor_row.* = rows.items.len; const row = try self.renderRow(chunk, cursor_col, cursor_style, width, focused); try rows.append(a, row); } // If the cursor is at the end of a line that exactly fills the last // visual row, show the cursor on the following wrapped row instead of // replacing the last visible character with the block. if (cursor_in_line and self.cursor == line_start + line.len and displayWidth(line) % width == 0) { const row = try self.renderRow("", @as(?usize, 0), cursor_style, width, focused); cursor_row.* = rows.items.len; try rows.append(a, row); } } /// Build a dim, full-width horizontal rule (box-drawing `─`). Caller owns /// the returned slice. fn horizontalRule(self: *InputBox, width: usize) ![]u8 { const a = self.alloc; const style = theme.default.fg(.dim); var line: std.ArrayList(u8) = .empty; errdefer line.deinit(a); try line.appendSlice(a, style.open()); var i: usize = 0; while (i < width) : (i += 1) try line.appendSlice(a, "\u{2500}"); try line.appendSlice(a, style.close()); return line.toOwnedSlice(a); } /// Compute the visible `[start, end)` row range for the scroll-window given /// the total produced rows and (optionally) the focused cursor's row. /// Returns the whole range when `total <= line_cap` (or the cap is /// 0/disabled). Otherwise returns a `line_cap`-tall window biased toward the /// TAIL: the default window is the last `line_cap` rows, sliding UP only as /// far as needed to keep `cursor_row` visible. A null `cursor_row` (no live /// cursor / unfocused) leaves the tail window in place. const Window = struct { start: usize, end: usize }; fn scrollWindow(self: *const InputBox, total: usize, cursor_row: ?usize) Window { const cap = self.line_cap; if (cap == 0 or total <= cap) return .{ .start = 0, .end = total }; // Default to the last `cap` rows. var start = total - cap; // Slide the window up if the focused cursor is above it (keep the cursor // row in view). The cursor is never below the tail window, so no // downward slide is needed. if (cursor_row) |cr| { if (cr < start) start = cr; } return .{ .start = start, .end = start + cap }; } /// Render one visual row. `cursor_col` is the byte offset within `line` /// where the cursor sits (null if the cursor isn't on this row). When /// present and focused, draws a reverse-video block over the glyph at the /// cursor (or a space at end-of-line) and emits CURSOR_MARKER there. fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 { const a = self.alloc; if (width == 0) return a.dupe(u8, ""); // The cursor block consumes one visible column, so usable text width // is width-1 when the cursor sits at/after the truncated end and we // must show the block. To keep it simple and always-safe: truncate the // plain line to `width` columns; if a cursor block would push us to // width+1, the block replaces the last column instead. const vis = truncateToCols(line, width); var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(a); if (cursor_col == null or !focused) { try buf.appendSlice(a, vis); return buf.toOwnedSlice(a); } // Cursor is on this row. Find the byte position within `vis`. const cc = cursor_col.?; const before_cols = displayWidth(line[0..@min(cc, line.len)]); if (cc >= line.len) { // Cursor at end-of-line: block over a trailing space. Ensure room: // if the visible text already fills `width`, drop its last column. var head = vis; if (before_cols >= width) { head = truncateToCols(line, width - 1); } try buf.appendSlice(a, head); try buf.appendSlice(a, CURSOR_MARKER); try buf.appendSlice(a, cursor_style.open()); try buf.appendSlice(a, " "); try buf.appendSlice(a, cursor_style.close()); return buf.toOwnedSlice(a); } // Cursor over an interior glyph. Split: head | glyph | tail. const glyph_len = blk: { const sl = std.unicode.utf8ByteSequenceLength(line[cc]) catch 1; break :blk @min(sl, line.len - cc); }; const head = line[0..cc]; const glyph = line[cc .. cc + glyph_len]; const tail = line[cc + glyph_len ..]; // Compose head + marker + [reverse]glyph[/] + tail, then truncate the // whole visible width to `width` columns (escapes + marker are // zero-width, so truncation acts on glyphs). try buf.appendSlice(a, head); try buf.appendSlice(a, CURSOR_MARKER); try buf.appendSlice(a, cursor_style.open()); try buf.appendSlice(a, glyph); try buf.appendSlice(a, cursor_style.close()); try buf.appendSlice(a, tail); // The composed row's visible width == displayWidth(line). If that // exceeds `width`, rebuild with a width-bounded tail. (Rare: cursor // near a long line's start.) Simpler safe path: if over, truncate the // tail. if (displayWidth(line) > width) { buf.clearRetainingCapacity(); // Keep head+glyph; truncate tail to remaining columns. const used = before_cols + 1; // head cols + the glyph try buf.appendSlice(a, head); try buf.appendSlice(a, CURSOR_MARKER); try buf.appendSlice(a, cursor_style.open()); try buf.appendSlice(a, glyph); try buf.appendSlice(a, cursor_style.close()); if (used < width) { const remaining = width - used; try buf.appendSlice(a, truncateToCols(tail, remaining)); } } return buf.toOwnedSlice(a); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *InputBox = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *InputBox = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, .handleInput = handleInputImpl, }; pub fn comp(self: *InputBox) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // Footer — persistent bottom line (plan §6) // =========================================================================== /// 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, /// Model info string (borrowed; copied into a small owned buffer on set). model: std.ArrayList(u8) = .empty, /// Latest context-window size in tokens (null = no usage reported yet). /// Overwritten on each `message_complete` with the most recent value, not /// accumulated. Defined (plan §6) as /// `usage.input + usage.cache_read + usage.cache_write`. context_tokens: ?u64 = null, /// The active model's declared context window (models.toml /// `context_window`), so the context readout renders as a fraction /// ("12.3k/200k ctx"). Null = window unknown; raw count shown. context_window: ?u32 = null, /// Session short-id (first 8 chars), shown as "#0197c2a4". Empty until set. session_id: std.ArrayList(u8) = .empty, /// One transient context-dependent key hint (e.g. "esc interrupt" while a /// turn runs). Empty = no hint shown. hint: std.ArrayList(u8) = .empty, /// 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) }; } pub fn deinit(self: *Footer) void { self.model.deinit(self.alloc); self.session_id.deinit(self.alloc); self.hint.deinit(self.alloc); self.cache.deinit(); } /// Set the model info shown in the footer. pub fn setModel(self: *Footer, name: []const u8) !void { self.model.clearRetainingCapacity(); try self.model.appendSlice(self.alloc, name); self.cache.markDirty(); } /// Set the latest context-window token count (plan §6). The caller passes /// the already-summed `input + cache_read + cache_write`. Overwrites the /// previous value (latest-wins; null = back to "no usage yet", e.g. after /// `/new`) and marks dirty so the footer repaints. pub fn setContextTokens(self: *Footer, tokens: ?u64) void { self.context_tokens = tokens; self.cache.markDirty(); } /// Set (or clear) the active model's context window, for the fractional /// context readout. Updated on model switch. pub fn setContextWindow(self: *Footer, window: ?u32) void { self.context_window = window; self.cache.markDirty(); } /// Set the session short-id shown in the footer (first 8 chars kept). pub fn setSessionId(self: *Footer, id: []const u8) !void { self.session_id.clearRetainingCapacity(); try self.session_id.appendSlice(self.alloc, id[0..@min(8, id.len)]); self.cache.markDirty(); } /// Set the transient key hint ("" clears it). pub fn setHint(self: *Footer, text: []const u8) !void { self.hint.clearRetainingCapacity(); try self.hint.appendSlice(self.alloc, text); 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 ""; const w = self.context_window orelse return formatTokenShort(buf, n, " ctx"); var n_buf: [16]u8 = undefined; var w_buf: [16]u8 = undefined; return std.fmt.bufPrint(buf, "{s}/{s} ctx", .{ formatTokenShort(&n_buf, n, ""), formatTokenShort(&w_buf, w, ""), }) catch ""; } /// 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 { _ = alloc; 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 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. // // <#sid> 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) { 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 (self.session_id.items.len != 0) { if (plain.items.len != 0) try plain.appendSlice(a, " "); try plain.append(a, '#'); try plain.appendSlice(a, self.session_id.items); } if (self.hint.items.len != 0) { if (plain.items.len != 0) try plain.appendSlice(a, " "); try plain.appendSlice(a, self.hint.items); } const vis = truncateToCols(plain.items, width); // Styled as dim chrome (no reverse video). const footer_style = theme.default.fg(.footer); const composed = try std.fmt.allocPrint(a, "{s}{s}{s}", .{ footer_style.open(), vis, footer_style.close() }); defer a.free(composed); const lines = [_][]const u8{composed}; try self.cache.store(&lines); return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *Footer = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *Footer = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *Footer) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // Selector — fuzzy-filter list overlay (model / reasoning pickers) // =========================================================================== /// One selectable row: a primary `label` plus optional dim `detail` text /// shown after it (e.g. the upstream model id and reasoning knobs). Both /// slices are BORROWED from the caller for the selector's lifetime. pub const SelectorItem = struct { label: []const u8, detail: []const u8 = "", }; /// The action a key produced against an open selector. pub const SelectorAction = enum { /// Nothing actionable yet (filter/navigation updated; keep the overlay open). none, /// The user pressed Enter on the highlighted item (`selectedIndex`). accept, /// The user pressed Escape (dismiss without applying). cancel, }; /// A modal fuzzy-filter list. Holds a borrowed item slice, an owned filter /// buffer, and a selection cursor over the FILTERED view. It renders a /// titled, bordered overlay: a title line, the live filter line, then the /// filtered items (highlighted selection + dim detail), capped to a window. /// /// Keys (driven via `applyKey`, returning a `SelectorAction`): /// - printable / backspace edit the filter (real-time typeahead), /// - Ctrl+N / Down move the selection down, Ctrl+P / Up move it up, /// - Enter accepts, Escape cancels. /// /// Filtering is a case-insensitive SUBSEQUENCE (fuzzy) match against the /// label; an empty filter matches everything. The filtered order preserves /// the source order. pub const Selector = struct { alloc: std.mem.Allocator, cache: RenderCache, /// Borrowed for the selector's lifetime. items: []const SelectorItem, title: []const u8, /// Live filter text (owned). filter: std.ArrayList(u8) = .empty, /// Selection cursor into the FILTERED list. selected: usize = 0, /// Scratch: indices of items passing the current filter (owned). filtered: std.ArrayList(usize) = .empty, /// Whether `filtered` has been computed at least once. initialized: bool = false, /// Max item rows shown at once. window: usize = 10, pub fn init(alloc: std.mem.Allocator, title: []const u8, items: []const SelectorItem) Selector { return .{ .alloc = alloc, .cache = RenderCache.init(alloc), .items = items, .title = title, }; } pub fn deinit(self: *Selector) void { self.filter.deinit(self.alloc); self.filtered.deinit(self.alloc); self.cache.deinit(); } /// Pre-select the row whose label equals `label` (no-op if absent). Called /// right after `init` so the picker opens on the current value. pub fn selectLabel(self: *Selector, label: []const u8) !void { try self.recompute(); for (self.filtered.items, 0..) |idx, i| { if (std.mem.eql(u8, self.items[idx].label, label)) { self.selected = i; self.cache.markDirty(); return; } } } /// Seed the filter with already-typed text (the leading-`/` typeahead /// opens mid-word). Recomputes the filtered view. pub fn setFilter(self: *Selector, text: []const u8) !void { self.filter.clearRetainingCapacity(); try self.filter.appendSlice(self.alloc, text); try self.recompute(); self.cache.markDirty(); } /// The source-item index currently highlighted, or null when the filtered /// list is empty. pub fn selectedIndex(self: *const Selector) ?usize { if (self.selected >= self.filtered.items.len) return null; return self.filtered.items[self.selected]; } /// Case-insensitive subsequence test: every char of `needle` appears in /// `haystack` in order. Empty needle always matches. fn fuzzyMatch(haystack: []const u8, needle: []const u8) bool { if (needle.len == 0) return true; var ni: usize = 0; for (haystack) |hc| { if (ni >= needle.len) break; if (std.ascii.toLower(hc) == std.ascii.toLower(needle[ni])) ni += 1; } return ni == needle.len; } /// Rebuild `filtered` from the current filter text, clamping `selected`. fn recompute(self: *Selector) !void { self.initialized = true; self.filtered.clearRetainingCapacity(); for (self.items, 0..) |it, idx| { if (fuzzyMatch(it.label, self.filter.items)) { try self.filtered.append(self.alloc, idx); } } if (self.filtered.items.len == 0) { self.selected = 0; } else if (self.selected >= self.filtered.items.len) { self.selected = self.filtered.items.len - 1; } } /// Feed one decoded key. Returns the resulting action. Edits the filter, /// moves the selection, or signals accept/cancel. pub fn applyKey(self: *Selector, k: key.Key) !SelectorAction { if (!self.initialized) try self.recompute(); switch (k.code) { .escape => return .cancel, .enter => return if (self.selectedIndex() == null) .none else .accept, .up => { self.moveUp(); return .none; }, .down => { self.moveDown(); return .none; }, .backspace => { self.deleteFilterChar(); try self.recompute(); self.cache.markDirty(); return .none; }, .char => |cp| { // Ctrl-chords: navigation + the readline line-editing niceties. if (k.mods.ctrl) { if (k.isCtrl('n')) { self.moveDown(); } else if (k.isCtrl('p')) { self.moveUp(); } else if (k.isCtrl('u')) { // Clear the whole filter. self.clearFilter(); try self.recompute(); self.cache.markDirty(); } else if (k.isCtrl('w')) { // Delete the trailing word. self.deleteFilterWord(); try self.recompute(); self.cache.markDirty(); } else if (k.isCtrl('h')) { // Ctrl+H is the ASCII backspace some terminals emit. self.deleteFilterChar(); try self.recompute(); self.cache.markDirty(); } // Other ctrl chords: ignored. return .none; } // Alt+Backspace-as-word-delete: some terminals send `ESC ` // forms, but the common word-delete in a finder is Ctrl+W // (handled above). Alt-modified chars are not filter input. if (k.mods.alt or k.mods.super) return .none; // Append the printable codepoint to the filter. if (k.text) |t| { try self.filter.appendSlice(self.alloc, t); } else { var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch return .none; try self.filter.appendSlice(self.alloc, buf[0..n]); } try self.recompute(); self.cache.markDirty(); return .none; }, else => return .none, } } fn moveUp(self: *Selector) void { if (self.filtered.items.len == 0) return; if (self.selected == 0) { self.selected = self.filtered.items.len - 1; // wrap to bottom } else { self.selected -= 1; } self.cache.markDirty(); } fn moveDown(self: *Selector) void { if (self.filtered.items.len == 0) return; self.selected = (self.selected + 1) % self.filtered.items.len; self.cache.markDirty(); } fn deleteFilterChar(self: *Selector) void { if (self.filter.items.len == 0) return; // Drop one UTF-8 codepoint from the tail. var i = self.filter.items.len; i -= 1; while (i > 0 and (self.filter.items[i] & 0xC0) == 0x80) i -= 1; self.filter.items.len = i; } /// Ctrl+U: clear the entire filter. fn clearFilter(self: *Selector) void { self.filter.clearRetainingCapacity(); } /// Ctrl+W: delete the trailing "word" — first any trailing spaces, then the /// run of non-space bytes before them (readline word-kill semantics). fn deleteFilterWord(self: *Selector) void { var i = self.filter.items.len; while (i > 0 and self.filter.items[i - 1] == ' ') i -= 1; while (i > 0 and self.filter.items[i - 1] != ' ') i -= 1; self.filter.items.len = i; } /// Compute the `[start, end)` window over the filtered list keeping the /// selection visible (mirrors the input box scroll-window). fn windowRange(self: *const Selector) struct { start: usize, end: usize } { const total = self.filtered.items.len; if (total <= self.window) return .{ .start = 0, .end = total }; var start: usize = 0; if (self.selected >= self.window) start = self.selected - self.window + 1; const end = @min(start + self.window, total); return .{ .start = start, .end = end }; } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *Selector = @ptrCast(@alignCast(ptr)); const a = self.alloc; // The selector may render before any key arrives; ensure `filtered` is // populated. if (!self.initialized) try self.recompute(); const dim = theme.default.fg(.dim); const accent = theme.default.fg(.welcome); const sel_style = theme.default.fg(.cursor); // reverse video highlight var rows: std.ArrayList([]const u8) = .empty; defer { for (rows.items) |r| a.free(r); rows.deinit(a); } // Title line: " (n)". Build PLAIN, truncate, then style (escapes // are zero-width but `truncateToCols` counts bytes, so we must truncate // the plain text first — mirrors the Footer). { const plain = try std.fmt.allocPrint(a, "{s} ({d})", .{ self.title, self.filtered.items.len }); defer a.free(plain); const vis = truncateToCols(plain, width); try rows.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() })); } // Filter line: "> <filter>". { const plain = try std.fmt.allocPrint(a, "> {s}", .{self.filter.items}); defer a.free(plain); const vis = truncateToCols(plain, width); try rows.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); } const win = self.windowRange(); for (self.filtered.items[win.start..win.end], win.start..) |idx, i| { const it = self.items[idx]; const is_sel = (i == self.selected); const marker = if (is_sel) "› " else " "; try rows.append(a, try self.renderItemRow(marker, it, is_sel, width, sel_style, dim)); } if (self.filtered.items.len == 0) { const vis = truncateToCols(" (no matches)", width); try rows.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); } try self.cache.store(rows.items); return cacheLines(&self.cache); } /// Render one item row. Computes column budgets on PLAIN text so the /// dim-detail span and reverse-video highlight survive width truncation. fn renderItemRow( self: *Selector, marker: []const u8, it: SelectorItem, is_sel: bool, width: usize, sel_style: Style, dim: Style, ) ![]u8 { const a = self.alloc; // Truncate label to fit after the marker. const marker_w = displayWidth(marker); const label_budget = if (width > marker_w) width - marker_w else 0; const label = truncateToCols(it.label, label_budget); var used = marker_w + displayWidth(label); // Detail rides after two spaces if there is room. var detail: []const u8 = ""; if (it.detail.len != 0 and used + 2 < width) { detail = truncateToCols(it.detail, width - used - 2); if (detail.len != 0) used += 2 + displayWidth(detail); } var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(a); if (is_sel) { // Whole row reverse-video (detail included). try buf.appendSlice(a, sel_style.open()); try buf.appendSlice(a, marker); try buf.appendSlice(a, label); if (detail.len != 0) { try buf.appendSlice(a, " "); try buf.appendSlice(a, detail); } try buf.appendSlice(a, sel_style.close()); } else { try buf.appendSlice(a, marker); try buf.appendSlice(a, label); if (detail.len != 0) { try buf.appendSlice(a, " "); try buf.appendSlice(a, dim.open()); try buf.appendSlice(a, detail); try buf.appendSlice(a, dim.close()); } } return buf.toOwnedSlice(a); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *Selector = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *Selector = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *Selector) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // Welcome — session-start banner // =========================================================================== /// A static banner shown as the first transcript entry at session start. /// Structured data in (version / cwd via setters), lines out. Re-rendered /// only when one of the visible fields changes (markDirty), which in practice /// is once during bring-up. pub const Welcome = struct { alloc: std.mem.Allocator, cache: RenderCache, version: std.ArrayList(u8) = .empty, cwd: std.ArrayList(u8) = .empty, pub fn init(alloc: std.mem.Allocator) Welcome { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *Welcome) void { self.version.deinit(self.alloc); self.cwd.deinit(self.alloc); self.cache.deinit(); } fn setField(self: *Welcome, field: *std.ArrayList(u8), value: []const u8) !void { field.clearRetainingCapacity(); try field.appendSlice(self.alloc, value); self.cache.markDirty(); } /// Set the panto version string (e.g. "0.1.0"). pub fn setVersion(self: *Welcome, value: []const u8) !void { try self.setField(&self.version, value); } /// Set the working directory shown in the banner. pub fn setCwd(self: *Welcome, value: []const u8) !void { try self.setField(&self.cwd, value); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *Welcome = @ptrCast(@alignCast(ptr)); const a = self.alloc; const accent = theme.default.fg(.welcome); const dim = theme.default.fg(.dim); const plain_bg = theme.default.fg(.assistant); // no-op bg // Transient owned lines; freed after the cache dupes them. var lines: std.ArrayList([]const u8) = .empty; defer { for (lines.items) |l| a.free(l); lines.deinit(a); } // Blank margin above. try lines.append(a, try a.dupe(u8, "")); // Title line: "panto v<version>" in the accent color, padded to width. { const title_plain = if (self.version.items.len != 0) try std.fmt.allocPrint(a, " panto v{s}", .{self.version.items}) else try std.fmt.allocPrint(a, " panto", .{}); defer a.free(title_plain); const vis = truncateToCols(title_plain, width); try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() })); } // Detail lines (dim, with leading space): cwd only when set. if (self.cwd.items.len != 0) { const txt = try std.fmt.allocPrint(a, " cwd: {s}", .{self.cwd.items}); defer a.free(txt); const vis = truncateToCols(txt, width); try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); } _ = plain_bg; // Blank margin below. try lines.append(a, try a.dupe(u8, "")); try self.cache.store(lines.items); return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *Welcome = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *Welcome = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *Welcome) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // Thinking — streaming thinking deltas (plan §6: dimmed; streams) // =========================================================================== /// Accumulates thinking content deltas into an internal buffer and renders the /// wrapped text with the theme's `thinking` (dim) style. Shares AssistantText's /// streaming-tail dirty model (`appendDelta` + `markDirtyAppend`), so the cut /// stays near the tail while reasoning streams. It is its OWN component type /// (not a styled AssistantText) so the component taxonomy is honest: the engine /// and any future event/handler logic can distinguish a thinking block from an /// assistant body. pub const Thinking = struct { alloc: std.mem.Allocator, buffer: std.ArrayList(u8) = .empty, cache: RenderCache, pub fn init(alloc: std.mem.Allocator) Thinking { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *Thinking) void { self.buffer.deinit(self.alloc); self.cache.deinit(); } /// Append a streaming thinking delta. Retains the baseline so the cache /// diff recovers the true tail change point (firstLineChanged near the end). pub fn appendDelta(self: *Thinking, delta: []const u8) !void { try self.buffer.appendSlice(self.alloc, delta); self.cache.markDirtyAppend(); } /// Replace the whole buffer. Marks dirty. pub fn setText(self: *Thinking, text: []const u8) !void { self.buffer.clearRetainingCapacity(); try self.buffer.appendSlice(self.alloc, text); self.cache.markDirty(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *Thinking = @ptrCast(@alignCast(ptr)); const plain = theme.default.fg(.assistant); const header = theme.default.fg(.thinking_header); const body = theme.default.fg(.thinking); var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| self.alloc.free(l); out.deinit(self.alloc); } const styled_header = try std.fmt.allocPrint(self.alloc, "{s}thinking{s}", .{ header.open(), theme.reset }); defer self.alloc.free(styled_header); try renderBlock(&out, styled_header, plain, plain, width, 1, 0, self.alloc); if (self.buffer.items.len != 0) { try renderBlock(&out, self.buffer.items, plain, body, width, 1, 0, self.alloc); } try self.cache.store(out.items); return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *Thinking = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *Thinking = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *Thinking) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // CompactionSummary — shown when context is compacted (plan §6) // =========================================================================== /// Renders a compaction summary (the synthetic seed text that replaces a /// compacted conversation prefix). Structured data in (the summary string via /// `setSummary`), lines out. Styled as dim chrome with a short prefix so it /// reads as a system event rather than assistant prose. pub const CompactionSummary = struct { alloc: std.mem.Allocator, buffer: std.ArrayList(u8) = .empty, cache: RenderCache, pub fn init(alloc: std.mem.Allocator) CompactionSummary { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *CompactionSummary) void { self.buffer.deinit(self.alloc); self.cache.deinit(); } /// Set the compaction summary text. Marks dirty. pub fn setSummary(self: *CompactionSummary, text: []const u8) !void { self.buffer.clearRetainingCapacity(); try self.buffer.appendSlice(self.alloc, text); self.cache.markDirty(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *CompactionSummary = @ptrCast(@alignCast(ptr)); const a = self.alloc; const header = theme.default.fg(.compaction_header); const dim = theme.default.fg(.compaction); var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| a.free(l); out.deinit(a); } const styled_header = try std.fmt.allocPrint(a, "{s}[context compacted]{s}", .{ header.open(), theme.reset }); defer a.free(styled_header); try renderBlock(&out, styled_header, theme.default.fg(.assistant), theme.default.fg(.assistant), width, 1, 0, a); if (self.buffer.items.len != 0) { try renderBlock(&out, self.buffer.items, theme.default.fg(.assistant), dim, width, 1, 0, a); } try self.cache.store(out.items); return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *CompactionSummary = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *CompactionSummary = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *CompactionSummary) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // 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. Extension-provided tools own /// their display by registering event handlers and setting their component. /// /// 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): /// /// 1. At creation (block_start), name unknown: `tool (?)…` /// 2. Once args finish streaming (name known): `tool (<name>) <input json>` /// followed by a blank line and `(…)` as a result placeholder. /// 3. Once the result lands: `tool (<name>) <input json>` /// followed by a blank line and the result output text. /// /// The input JSON is rendered VERBATIM (no pretty-print), terminal-wrapped to /// width. It accumulates from the ToolUse block's `content_delta`s (the deltas /// ARE the streaming JSON args), or is set wholesale from the completed block. /// /// Collapsing (ctrl+o) is a GLOBAL toggle driven by the app: it calls /// `setCollapsed(bool)` on every ToolUse component. Default is COLLAPSED. When /// collapsed, only the LAST 5 lines of the wrapped output are shown (with a /// leading `…` marker line when output was truncated); expanded shows all of /// it. Collapsing is a length change — the RenderCache diff and the engine's /// line-diff backstop handle the shrink; the component just re-renders fewer /// lines and marks dirty. /// /// No "active component" (plan §6): the app keys each ToolUse instance by the /// libpanto block index AND by tool-call id (for result correlation). This /// component holds no global state. pub const ToolUse = struct { /// Number of trailing output lines shown when collapsed. pub const collapsed_tail_lines: usize = 8; alloc: std.mem.Allocator, cache: RenderCache, /// Resolved tool name, or null until `tool_details`/completion. name: ?std.ArrayList(u8) = null, /// Tool-call id, once resolved. id: ?std.ArrayList(u8) = null, /// Accumulated verbatim input JSON (streamed args). input: std.ArrayList(u8) = .empty, /// Result output text, or null until the result lands. output: ?std.ArrayList(u8) = null, /// Whether the output is collapsed to its tail. Default true. collapsed: bool = true, /// Result status: null = still running, true = succeeded, false = errored. /// Set alongside (or after) `setOutput`; controls the background colour. result_ok: ?bool = null, pub fn init(alloc: std.mem.Allocator) ToolUse { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; } pub fn deinit(self: *ToolUse) void { if (self.name) |*n| n.deinit(self.alloc); if (self.id) |*i| i.deinit(self.alloc); self.input.deinit(self.alloc); if (self.output) |*o| o.deinit(self.alloc); self.cache.deinit(); } /// Resolve the tool-call id. pub fn setId(self: *ToolUse, id: []const u8) !void { if (id.len == 0) return; if (self.id) |existing| { if (std.mem.eql(u8, existing.items, id)) return; self.id.?.clearRetainingCapacity(); } else { self.id = .empty; } try self.id.?.appendSlice(self.alloc, id); self.cache.markDirty(); } /// Resolve the tool name (from `tool_details` or the completed block). pub fn setName(self: *ToolUse, name: []const u8) !void { if (self.name) |existing| { if (std.mem.eql(u8, existing.items, name)) return; self.name.?.clearRetainingCapacity(); } else { self.name = .empty; } try self.name.?.appendSlice(self.alloc, name); self.cache.markDirty(); } /// Append a streaming args delta (verbatim JSON bytes). pub fn appendInput(self: *ToolUse, delta: []const u8) !void { try self.input.appendSlice(self.alloc, delta); // Input arrives as an append-only stream. Keep the previous render as // the diff baseline so the engine can roll back only to the first // header line that actually changed instead of conservatively cutting // from the top of the tool (and reprinting everything below it) on // every args chunk. self.cache.markDirtyAppend(); } /// Replace the input verbatim (e.g. from the completed block's `input`). pub fn setInput(self: *ToolUse, value: []const u8) !void { if (std.mem.eql(u8, self.input.items, value)) return; self.input.clearRetainingCapacity(); try self.input.appendSlice(self.alloc, value); self.cache.markDirty(); } /// Set the result output text. Transitions render stage 2 -> 3. pub fn setOutput(self: *ToolUse, value: []const u8) !void { if (self.output == null) self.output = .empty; self.output.?.clearRetainingCapacity(); // Strip ANSI escapes once on ingest so colour codes from commands // don't corrupt the component's own background styling. try stripAnsi(value, &self.output.?, self.alloc); self.cache.markDirty(); } /// Record whether the tool call succeeded or failed (for bg colour). /// Call this alongside or after `setOutput`. pub fn setResultOk(self: *ToolUse, ok: bool) void { if (self.result_ok != null and self.result_ok.? == ok) return; self.result_ok = ok; self.cache.markDirty(); } /// Global collapse toggle target. The app calls this on every ToolUse /// component when ctrl+o is pressed. A no-op state change skips the dirty. pub fn setCollapsed(self: *ToolUse, value: bool) void { if (self.collapsed == value) return; self.collapsed = value; self.cache.markDirty(); } fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *ToolUse = @ptrCast(@alignCast(ptr)); const a = self.alloc; // Choose background based on result state: // null -> pending (dark blue-gray) // true -> success (dark green) // false -> error (dark red) const bg = if (self.result_ok) |ok| switch (ok) { true => theme.default.fg(.tool_success_bg), false => theme.default.fg(.tool_error_bg), } else theme.default.fg(.tool_pending_bg); const header_fg = theme.default.fg(.tool_header); const pending_fg = theme.default.fg(.tool_pending); const success_fg = theme.default.fg(.tool_success); const error_fg = theme.default.fg(.tool_error); // Padding within the block (1 col each side matches pi). const pad: usize = 1; const inner_w = if (width > 2 * pad) width - 2 * pad else 1; const indent = " "; // pad_x = 1 // Helper: emit one full-width bg line with the given fg text. // `text` is ALREADY truncated to inner_w. const Ctx = struct { a: std.mem.Allocator, bg: Style, width: usize, fn line(ctx: @This(), fg: Style, text: []const u8) ![]u8 { 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); @memset(rp, ' '); const inner = try reapplyAfterReset(ctx.a, text, ctx.bg.open(), fg.open()); defer ctx.a.free(inner); if (std.mem.indexOfScalar(u8, text, '\t') != null) { const prepaint = try ctx.a.alloc(u8, ctx.width); defer ctx.a.free(prepaint); @memset(prepaint, ' '); return std.fmt.allocPrint(ctx.a, "{s}{s}\r{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), prepaint, ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset }); } return std.fmt.allocPrint(ctx.a, "{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset }); } fn blank(ctx: @This()) ![]u8 { return ctx.line(.{ .open_seq = "", .is_plain = true }, ""); } }; const ctx: Ctx = .{ .a = a, .bg = bg, .width = width }; // Transient owned lines; the cache dupes them. var lines: std.ArrayList([]const u8) = .empty; defer { for (lines.items) |l| a.free(l); lines.deinit(a); } // Blank margin ABOVE (no bg colour). try lines.append(a, try a.dupe(u8, "")); // -- Stage 1: name not yet known ----------------------------------- if (self.name == null) { try lines.append(a, try ctx.blank()); const vis = truncateToCols("tool (?)…", inner_w); try lines.append(a, try ctx.line(pending_fg, vis)); try lines.append(a, try ctx.blank()); try lines.append(a, try a.dupe(u8, "")); try self.cache.store(lines.items); return cacheLines(&self.cache); } // -- 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()); { var wrapped: std.ArrayList([]const u8) = .empty; defer wrapped.deinit(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)); } } // Separator blank row inside the block. try lines.append(a, try ctx.blank()); const result_fg = if (self.result_ok) |ok| switch (ok) { true => success_fg, false => error_fg, } else pending_fg; // -- Result region: `(…)` placeholder or output text. if (self.output == null) { const vis = truncateToCols("(…)", inner_w); try lines.append(a, try ctx.line(result_fg, vis)); } else { var out_lines: std.ArrayList([]const u8) = .empty; defer out_lines.deinit(a); try wrapBuffer(self.output.?.items, inner_w, &out_lines, a); var start: usize = 0; var truncated = false; if (self.collapsed and out_lines.items.len > collapsed_tail_lines) { start = out_lines.items.len - collapsed_tail_lines; truncated = true; } if (truncated) { const vis = truncateToCols("\xe2\x80\xa6", inner_w); try lines.append(a, try ctx.line(result_fg, vis)); } for (out_lines.items[start..]) |oline| { const vis = truncateStyledToCols(oline, inner_w); try lines.append(a, try ctx.line(result_fg, vis)); } } // Bottom padding row inside the block. try lines.append(a, try ctx.blank()); // Blank margin BELOW (no bg colour). try lines.append(a, try a.dupe(u8, "")); try self.cache.store(lines.items); return cacheLines(&self.cache); } fn firstLineChangedImpl(ptr: *anyopaque) ?usize { const self: *ToolUse = @ptrCast(@alignCast(ptr)); return self.cache.firstLineChanged(); } fn invalidateImpl(ptr: *anyopaque) void { const self: *ToolUse = @ptrCast(@alignCast(ptr)); self.cache.invalidate(); } const vtable = Component.VTable{ .render = renderImpl, .firstLineChanged = firstLineChangedImpl, .invalidate = invalidateImpl, }; pub fn comp(self: *ToolUse) Component { return .{ .ptr = self, .vtable = &vtable }; } }; // =========================================================================== // Tests // =========================================================================== const testing = std.testing; const engine = @import("tui_engine.zig"); /// Visible width of a rendered (possibly styled, possibly marker-bearing) line, /// reusing the engine's authoritative measure. fn vw(line: []const u8) usize { return engine.visibleWidth(line); } // -- helpers --------------------------------------------------------------- test "displayWidth counts codepoints; truncateToCols respects boundaries" { try testing.expectEqual(@as(usize, 3), displayWidth("abc")); try testing.expectEqual(@as(usize, 3), displayWidth("aé✓")); try testing.expectEqual(@as(usize, 8), displayWidth("\t")); try testing.expectEqual(@as(usize, 9), displayWidth("\tX")); try testing.expectEqual(@as(usize, 9), displayWidth("a\tX")); try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2)); try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3)); try testing.expectEqualStrings("", truncateToCols("\tX", 7)); try testing.expectEqualStrings("\t", truncateToCols("\tX", 8)); // Never splits a multibyte codepoint. const t = truncateToCols("é", 1); try testing.expectEqualStrings("é", t); // Wide emoji count as 2 columns. try testing.expectEqual(@as(usize, 2), codepointWidth('🤖')); try testing.expectEqual(@as(usize, 9), displayWidth("hello 🤖 ")); // truncateToCols must not split a 2-wide emoji across a 1-col boundary. try testing.expectEqualStrings("hello ", truncateToCols("hello 🤖", 7)); try testing.expectEqualStrings("hello 🤖", truncateToCols("hello 🤖", 8)); } test "displayWidthStyled treats OSC hyperlinks as zero width" { try testing.expectEqual(@as(usize, 4), displayWidthStyled("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\")); try testing.expectEqualStrings("\x1b]8;;https://example.com\x1b\\link", truncateStyledToCols("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\", 4)); } test "displayWidthStyled counts styled hard tabs as tab stops" { try testing.expectEqual(@as(usize, 8), displayWidthStyled("\x1b[48;2;40;50;40m\t\x1b[0m")); try testing.expectEqual(@as(usize, 9), displayWidthStyled("\x1b[48;2;40;50;40m\tX\x1b[0m")); } test "wrapParagraph word-wraps and hard-splits long words" { var out: std.ArrayList([]const u8) = .empty; defer out.deinit(testing.allocator); try wrapParagraph("hello world foo", 7, &out, testing.allocator); try testing.expectEqual(@as(usize, 3), out.items.len); try testing.expectEqualStrings("hello", out.items[0]); try testing.expectEqualStrings("world", out.items[1]); try testing.expectEqualStrings("foo", out.items[2]); out.clearRetainingCapacity(); try wrapParagraph("abcdefghij", 4, &out, testing.allocator); try testing.expectEqual(@as(usize, 3), out.items.len); try testing.expectEqualStrings("abcd", out.items[0]); try testing.expectEqualStrings("efgh", out.items[1]); try testing.expectEqualStrings("ij", out.items[2]); out.clearRetainingCapacity(); try wrapParagraph("\treturn err", 14, &out, testing.allocator); try testing.expectEqual(@as(usize, 2), out.items.len); try testing.expectEqualStrings("\treturn", out.items[0]); try testing.expectEqualStrings("err", out.items[1]); } // -- AssistantText --------------------------------------------------------- test "AssistantText: renders wrapped text within width" { var at = AssistantText.init(testing.allocator); defer at.deinit(); try at.setText("hello world foo"); const lines = try at.comp().render(7, testing.allocator); // renderBlock adds 2 margin lines: 3 content + 2 = 5. try testing.expectEqual(@as(usize, 5), lines.len); for (lines) |l| try testing.expect(vw(l) <= 7); // After a render the cache is clean, so the live signal is null. (The // first-render diff index 0 is retained internally in changed_from.) try testing.expectEqual(@as(?usize, null), at.comp().firstLineChanged()); } test "AssistantText: streaming keeps firstLineChanged near the tail, not 0" { var at = AssistantText.init(testing.allocator); defer at.deinit(); // Seed several wrapped lines. try at.setText("alpha beta gamma delta epsilon"); _ = try at.comp().render(11, testing.allocator); const lines1 = at.cache.lines.?.len; try testing.expect(lines1 >= 3); // Append a delta to the tail; earlier wrapped lines stay byte-identical. try at.appendDelta(" zeta"); // While dirty, firstLineChanged reports 0 (full markDirty). The KEY // property is what the cache reports AFTER the render: the lowest line that // actually changed must be near the tail, not 0. _ = try at.comp().render(11, testing.allocator); const fc = at.comp().firstLineChanged(); // The change landed on the last line(s); the cut must be > 0. try testing.expect(fc == null or fc.? > 0); // Stronger: the first changed line should be at the tail region. if (fc) |v| try testing.expect(v >= lines1 - 1); } test "AssistantText: width truncation on a long unbroken word" { var at = AssistantText.init(testing.allocator); defer at.deinit(); try at.setText("supercalifragilistic"); const lines = try at.comp().render(5, testing.allocator); for (lines) |l| try testing.expect(vw(l) <= 5); } // -- UserText --------------------------------------------------------------- test "UserText: renders user-styled lines within width" { var ut = UserText.init(testing.allocator); defer ut.deinit(); try ut.setText("a user message that wraps"); const lines = try ut.comp().render(10, testing.allocator); for (lines) |l| try testing.expect(vw(l) <= 10); // Static: re-render with no change => clean. _ = try ut.comp().render(10, testing.allocator); try testing.expectEqual(@as(?usize, null), ut.comp().firstLineChanged()); } // -- InputBox --------------------------------------------------------------- fn charKey(c: u21, text: []const u8) Key { return .{ .code = .{ .char = c }, .text = text }; } test "InputBox: insert printable chars and render with cursor block when focused" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.setFocused(true); try ib.applyKey(charKey('h', "h")); try ib.applyKey(charKey('i', "i")); const lines = try ib.comp().render(20, testing.allocator); // Two rules (top/bottom) wrap one content row. try testing.expectEqual(@as(usize, 3), lines.len); try testing.expect(vw(lines[1]) <= 20); // Focused => emits CURSOR_MARKER and reverse-video style on the content row. try testing.expect(std.mem.indexOf(u8, lines[1], CURSOR_MARKER) != null); try testing.expect(std.mem.indexOf(u8, lines[1], "\x1b[7m") != null); try testing.expect(std.mem.indexOf(u8, lines[1], "hi") != null); } test "InputBox: not focused emits no cursor marker" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try ib.applyKey(charKey('x', "x")); const lines = try ib.comp().render(20, testing.allocator); try testing.expect(std.mem.indexOf(u8, lines[1], CURSOR_MARKER) == null); } test "InputBox: backspace, delete, and cursor movement" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); for ("abc") |c| try ib.applyKey(charKey(c, &[_]u8{c})); try testing.expectEqual(@as(usize, 3), ib.cursor); ib.backspace(); // "ab" try testing.expectEqualStrings("ab", ib.text.items); try testing.expectEqual(@as(usize, 2), ib.cursor); ib.moveLeft(); // cursor at 1 try testing.expectEqual(@as(usize, 1), ib.cursor); ib.deleteForward(); // delete 'b' -> "a" try testing.expectEqualStrings("a", ib.text.items); ib.moveHome(); try testing.expectEqual(@as(usize, 0), ib.cursor); ib.moveEnd(); try testing.expectEqual(@as(usize, 1), ib.cursor); } test "InputBox: multibyte backspace removes a whole codepoint" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try ib.applyKey(charKey('é', "é")); // 2 bytes try testing.expectEqual(@as(usize, 2), ib.cursor); ib.backspace(); try testing.expectEqual(@as(usize, 0), ib.text.items.len); } test "InputBox: shift+enter inserts newline, enter submits" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); for ("ab") |c| try ib.applyKey(charKey(c, &[_]u8{c})); // Shift+Enter => newline (grows a row). try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); for ("cd") |c| try ib.applyKey(charKey(c, &[_]u8{c})); try testing.expectEqualStrings("ab\ncd", ib.text.items); const lines = try ib.comp().render(20, testing.allocator); // Two content rows wrapped by top/bottom rules. try testing.expectEqual(@as(usize, 4), lines.len); // Plain Enter => submit, editor cleared, pollable buffer set. try ib.applyKey(.{ .code = .enter }); const got = ib.takeSubmitted(); try testing.expect(got != null); try testing.expectEqualStrings("ab\ncd", got.?); try testing.expectEqual(@as(usize, 0), ib.text.items.len); // Second poll returns null. try testing.expect(ib.takeSubmitted() == null); } test "InputBox: handleInput decodes raw bytes (typing + enter)" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.comp().handleInput("hi\r"); // 'h' 'i' Enter const got = ib.takeSubmitted(); try testing.expect(got != null); try testing.expectEqualStrings("hi", got.?); } test "InputBox: handleInput kitty shift+enter inserts newline" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.comp().handleInput("a\x1b[13;2ub"); // 'a', shift+enter, 'b' try testing.expectEqualStrings("a\nb", ib.text.items); try testing.expect(ib.takeSubmitted() == null); // no plain enter yet } test "InputBox: firstLineChanged is cache-derived (clean after stable render)" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.setFocused(true); try ib.applyKey(charKey('x', "x")); _ = try ib.comp().render(20, testing.allocator); // Re-render with no state change => clean. _ = try ib.comp().render(20, testing.allocator); try testing.expectEqual(@as(?usize, null), ib.comp().firstLineChanged()); // Edit => dirty again. try ib.applyKey(charKey('y', "y")); try testing.expectEqual(@as(?usize, 0), ib.comp().firstLineChanged()); } test "InputBox: cursor block fits within width at end of a full line" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.setFocused(true); for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c})); // width 5, cursor at end: the block must not overflow. const lines = try ib.comp().render(5, testing.allocator); for (lines) |ln| try testing.expect(vw(ln) <= 5); } test "InputBox: long logical line hard-wraps visually without inserting newlines" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.setFocused(true); try typeStr(&ib, "abcdef"); const lines = try ib.comp().render(5, testing.allocator); // Two content rows ("abcde" and "f"+cursor), plus top/bottom rules. try testing.expectEqual(@as(usize, 4), lines.len); try testing.expectEqualStrings("abcdef", ib.text.items); try testing.expect(std.mem.indexOf(u8, lines[1], "abcde") != null); try testing.expect(std.mem.indexOf(u8, lines[2], "f") != null); try testing.expect(std.mem.indexOf(u8, lines[2], CURSOR_MARKER) != null); for (lines) |ln| try testing.expect(vw(ln) <= 5); } test "InputBox: line cap applies to visual wrapped rows" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.line_cap = 2; try typeStr(&ib, "abcde"); const lines = try ib.comp().render(2, testing.allocator); // Three wrapped content rows are capped to the tail two, plus rules. try testing.expectEqual(@as(usize, 4), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1], "cd") != null); try testing.expect(std.mem.indexOf(u8, lines[2], "e") != null); for (lines) |ln| try testing.expect(vw(ln) <= 2); } fn ctrlKey(letter: u8) Key { return .{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }; } fn typeStr(ib: *InputBox, s: []const u8) !void { for (s) |c| try ib.applyKey(charKey(c, &[_]u8{c})); } test "InputBox: alt+left / alt+right move by word" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "foo bar baz"); // cursor at 11 (end) try testing.expectEqual(@as(usize, 11), ib.cursor); // Alt+Left: jump to start of "baz" (byte 8). try ib.applyKey(.{ .code = .left, .mods = .{ .alt = true } }); try testing.expectEqual(@as(usize, 8), ib.cursor); // Again: start of "bar" (byte 4). try ib.applyKey(.{ .code = .left, .mods = .{ .alt = true } }); try testing.expectEqual(@as(usize, 4), ib.cursor); // Alt+Right: skip "bar" + the trailing space -> start of "baz" (byte 8). try ib.applyKey(.{ .code = .right, .mods = .{ .alt = true } }); try testing.expectEqual(@as(usize, 8), ib.cursor); // Ctrl+Left also performs word-motion (many terminals send 1;5D). try ib.applyKey(.{ .code = .left, .mods = .{ .ctrl = true } }); try testing.expectEqual(@as(usize, 4), ib.cursor); } test "InputBox: alt+arrow via RAW BYTES moves by word (handleInput pipeline)" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "foo bar baz"); // cursor at 11 try testing.expectEqual(@as(usize, 11), ib.cursor); // Kitty functional alt+left: CSI 57350 ; 3 u -> word-left to byte 8. ib.comp().handleInput("\x1b[57350;3u"); try testing.expectEqual(@as(usize, 8), ib.cursor); // Legacy CSI alt+left: 1;3D -> word-left to byte 4. ib.comp().handleInput("\x1b[1;3D"); try testing.expectEqual(@as(usize, 4), ib.cursor); // Alt+right via raw bytes -> back to byte 8. ib.comp().handleInput("\x1b[1;3C"); try testing.expectEqual(@as(usize, 8), ib.cursor); } test "InputBox: ESC b / ESC f (readline alt-word form) moves by word" { // Ghostty and most macOS terminals send Alt+Left/Right as the classic // readline `ESC b` / `ESC f`, which decode to alt+b / alt+f CHAR keys // rather than modified-arrow CSIs. These must move by word (and must NOT // be inserted as literal text). var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "foo bar baz"); // cursor at 11 try testing.expectEqual(@as(usize, 11), ib.cursor); ib.comp().handleInput("\x1bb"); // alt+b -> word-left to byte 8 try testing.expectEqual(@as(usize, 8), ib.cursor); ib.comp().handleInput("\x1bb"); // -> byte 4 try testing.expectEqual(@as(usize, 4), ib.cursor); ib.comp().handleInput("\x1bf"); // alt+f -> word-right to byte 8 try testing.expectEqual(@as(usize, 8), ib.cursor); // The alt-char must not have inserted any literal 'b'/'f' bytes. try testing.expectEqualStrings("foo bar baz", ib.text.items); } test "InputBox: alt/ctrl+backspace deletes the previous word" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "foo bar baz"); // cursor at 11 try ib.applyKey(.{ .code = .backspace, .mods = .{ .alt = true } }); try testing.expectEqualStrings("foo bar ", ib.text.items); try ib.applyKey(.{ .code = .backspace, .mods = .{ .ctrl = true } }); try testing.expectEqualStrings("foo ", ib.text.items); // Plain backspace still deletes a single codepoint. try ib.applyKey(.{ .code = .backspace }); try testing.expectEqualStrings("foo", ib.text.items); } test "InputBox: arrow press+release moves ONCE, not twice (no key-up double-move)" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "abcdef"); // cursor at 6 try testing.expectEqual(@as(usize, 6), ib.cursor); // A physical left-arrow press under the Kitty protocol would, with event // reporting on, arrive as a PRESS then a RELEASE. Feeding both must move // the cursor only once (the release is dropped). This guards the // double-move regression at the raw-bytes pipeline level even if a // terminal still emits releases. ib.comp().handleInput("\x1b[57350u"); // functional left press try testing.expectEqual(@as(usize, 5), ib.cursor); ib.comp().handleInput("\x1b[57350;1:3u"); // functional left RELEASE try testing.expectEqual(@as(usize, 5), ib.cursor); // unchanged // Same property for the legacy CSI release form via applyKey directly. try ib.applyKey(.{ .code = .left, .event = .release }); try testing.expectEqual(@as(usize, 5), ib.cursor); } test "InputBox: word-nav boundary cases (multiple spaces, newlines, buffer ends)" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); // Multiple spaces between words and a newline-separated logical line. try typeStr(&ib, "foo bar"); try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); // '\n' at byte 9 try typeStr(&ib, "baz"); // cursor at end (byte 13) try testing.expectEqual(@as(usize, 13), ib.cursor); // Word-left from end: start of "baz" (byte 10, just after the '\n'). ib.moveWordLeft(); try testing.expectEqual(@as(usize, 10), ib.cursor); // Again: crosses the newline and the multi-space run to the start of "bar" // (byte 6). ib.moveWordLeft(); try testing.expectEqual(@as(usize, 6), ib.cursor); // Again: start of "foo" (byte 0). ib.moveWordLeft(); try testing.expectEqual(@as(usize, 0), ib.cursor); // At the start, word-left is a clamped no-op. ib.moveWordLeft(); try testing.expectEqual(@as(usize, 0), ib.cursor); // Word-right skips "foo" + the multi-space run -> start of "bar" (byte 6). ib.moveWordRight(); try testing.expectEqual(@as(usize, 6), ib.cursor); // Jump to end, then word-right is a clamped no-op. ib.moveEnd(); const end = ib.cursor; ib.moveWordRight(); try testing.expectEqual(end, ib.cursor); } test "InputBox: ctrl+u on the FIRST logical line clears to byte 0" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); // Single logical line; ctrl+u from the end clears the whole line to 0 // (the first-line case, complementing the later-line case below). try typeStr(&ib, "hello world"); try ib.applyKey(ctrlKey('u')); try testing.expectEqualStrings("", ib.text.items); try testing.expectEqual(@as(usize, 0), ib.cursor); } test "InputBox: focused render emits CURSOR_MARKER exactly once" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.setFocused(true); // Multi-row buffer so we exercise the per-row cursor placement: the marker // must appear on exactly ONE row, once. try typeStr(&ib, "alpha"); try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); try typeStr(&ib, "beta"); const lines = try ib.comp().render(20, testing.allocator); var count: usize = 0; for (lines) |l| { var idx: usize = 0; while (std.mem.indexOfPos(u8, l, idx, CURSOR_MARKER)) |at| { count += 1; idx = at + CURSOR_MARKER.len; } } try testing.expectEqual(@as(usize, 1), count); } test "InputBox: ctrl+u deletes to start of the current logical line" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); // Two logical lines; cursor mid-second-line. try typeStr(&ib, "first"); try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); // newline try typeStr(&ib, "second"); // Cursor at end of "second"; ctrl+u clears just "second", keeping "first\n". try ib.applyKey(ctrlKey('u')); try testing.expectEqualStrings("first\n", ib.text.items); try testing.expectEqual(@as(usize, 6), ib.cursor); // just after the '\n' } test "InputBox: ctrl+w deletes the previous word (plain, no kill-ring)" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "hello world"); try ib.applyKey(ctrlKey('w')); // delete "world" try testing.expectEqualStrings("hello ", ib.text.items); try ib.applyKey(ctrlKey('w')); // delete "hello " try testing.expectEqualStrings("", ib.text.items); } test "InputBox: ctrl+a / ctrl+e move to line start / end" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "abc"); try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); try typeStr(&ib, "defg"); // second line, cursor at end (byte 8) try ib.applyKey(ctrlKey('a')); try testing.expectEqual(@as(usize, 4), ib.cursor); // start of "defg" try ib.applyKey(ctrlKey('e')); try testing.expectEqual(@as(usize, 8), ib.cursor); // end of "defg" } test "InputBox: line cap renders only the last cap rows by default" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.line_cap = 3; // 5 logical lines: L0..L4. Cursor ends on L4. try typeStr(&ib, "L0"); for (0..4) |i| { try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); var b: [2]u8 = .{ 'L', @intCast('1' + i) }; try typeStr(&ib, &b); } const lines = try ib.comp().render(20, testing.allocator); // Only `cap` content rows rendered (tail window L2, L3, L4), wrapped by // top/bottom rules. try testing.expectEqual(@as(usize, 5), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1], "L2") != null); try testing.expect(std.mem.indexOf(u8, lines[3], "L4") != null); for (lines) |ln| try testing.expect(vw(ln) <= 20); } test "InputBox: scroll-window slides up to keep the cursor visible" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); ib.setFocused(true); ib.line_cap = 3; try typeStr(&ib, "L0"); for (0..4) |i| { try ib.applyKey(.{ .code = .enter, .mods = .{ .shift = true } }); var b: [2]u8 = .{ 'L', @intCast('1' + i) }; try typeStr(&ib, &b); } // Move the cursor up to the top (L0) via Home then re-anchor: move cursor // to byte 0 so cursor_row == 0, above the default tail window. ib.moveHome(); const lines = try ib.comp().render(20, testing.allocator); // 3 content rows + top/bottom rules. try testing.expectEqual(@as(usize, 5), lines.len); // Window slid up so the cursor row (L0) is visible at the TOP content row // (lines[1]): the cursor block + marker render there (the cursor splits // "L0", so the marker is the reliable signal), and the rows below are L1, // L2 — proving the window is [0, 3) not the default tail [2, 5). try testing.expect(std.mem.indexOf(u8, lines[1], CURSOR_MARKER) != null); try testing.expect(std.mem.indexOf(u8, lines[2], "L1") != null); try testing.expect(std.mem.indexOf(u8, lines[3], "L2") != null); } test "InputBox: single-row default is unaffected by the cap" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try testing.expectEqual(InputBox.default_line_cap, ib.line_cap); try typeStr(&ib, "just one line"); const lines = try ib.comp().render(40, testing.allocator); // One content row + top/bottom rules. try testing.expectEqual(@as(usize, 3), lines.len); } test "InputBox: setBuffer/buffer round-trip for the $EDITOR hook" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "old"); try ib.setBuffer("new multi\nline text"); try testing.expectEqualStrings("new multi\nline text", ib.buffer()); // Cursor lands at the end. try testing.expectEqual(ib.text.items.len, ib.cursor); } test "InputBox: Up/Down history recall and multiline line motion" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); // Two submissions land in history (duplicates and empties skipped). try typeStr(&ib, "one"); try ib.applyKey(.{ .code = .enter }); _ = ib.takeSubmitted(); try typeStr(&ib, "two"); try ib.applyKey(.{ .code = .enter }); _ = ib.takeSubmitted(); try ib.applyKey(.{ .code = .enter }); // empty: not recorded _ = ib.takeSubmitted(); try testing.expectEqual(@as(usize, 2), ib.history.items.len); // Up walks back, stashing the live buffer; Down walks forward and // restores it. try typeStr(&ib, "draft"); try ib.applyKey(.{ .code = .up }); try testing.expectEqualStrings("two", ib.buffer()); try ib.applyKey(.{ .code = .up }); try testing.expectEqualStrings("one", ib.buffer()); try ib.applyKey(.{ .code = .up }); // at oldest: no-op try testing.expectEqualStrings("one", ib.buffer()); try ib.applyKey(.{ .code = .down }); try testing.expectEqualStrings("two", ib.buffer()); try ib.applyKey(.{ .code = .down }); try testing.expectEqualStrings("draft", ib.buffer()); // Multiline: Up/Down inside the buffer move by line, not history. try ib.setBuffer("ab\ncdef"); try ib.applyKey(.{ .code = .up }); // from end of "cdef", col 4 -> clamp to end of "ab" try testing.expectEqual(@as(usize, 2), ib.cursor); try testing.expectEqualStrings("ab\ncdef", ib.buffer()); // no recall happened try ib.applyKey(.{ .code = .down }); // col 2 on "cdef" try testing.expectEqual(@as(usize, 5), ib.cursor); } test "InputBox: external setBuffer resets history browsing" { var ib = InputBox.init(testing.allocator); defer ib.deinit(); try typeStr(&ib, "a"); try ib.applyKey(.{ .code = .enter }); _ = ib.takeSubmitted(); try typeStr(&ib, "b"); try ib.applyKey(.{ .code = .enter }); _ = ib.takeSubmitted(); // Browse back to "a", then an external clear (idle Escape) intervenes. try typeStr(&ib, "draft"); try ib.applyKey(.{ .code = .up }); try ib.applyKey(.{ .code = .up }); try testing.expectEqualStrings("a", ib.buffer()); try ib.setBuffer(""); // Up starts from the newest entry again, not the stale index. try ib.applyKey(.{ .code = .up }); try testing.expectEqualStrings("b", ib.buffer()); // Down past the newest restores the new (empty) stash, not "draft". try ib.applyKey(.{ .code = .down }); try testing.expectEqualStrings("", ib.buffer()); } // -- Footer ----------------------------------------------------------------- test "Footer: renders model dim, within width, no reverse video" { var ft = Footer.init(testing.allocator); defer ft.deinit(); try ft.setModel("anthropic:sonnet"); const lines = try ft.comp().render(80, testing.allocator); try testing.expectEqual(@as(usize, 1), lines.len); try testing.expect(vw(lines[0]) <= 80); // Dim styling present, reverse video absent. try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[2m") != null); try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") == null); try testing.expect(std.mem.indexOf(u8, lines[0], "anthropic:sonnet") != null); } test "Footer: shows model info and truncates to width" { var ft = Footer.init(testing.allocator); defer ft.deinit(); try ft.setModel("gpt-test-model"); const lines = try ft.comp().render(12, testing.allocator); try testing.expect(vw(lines[0]) <= 12); } test "Footer: setModel dirties; stable re-render is clean" { var ft = Footer.init(testing.allocator); defer ft.deinit(); try ft.setModel("m1"); _ = try ft.comp().render(80, testing.allocator); _ = try ft.comp().render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged()); try ft.setModel("m2"); try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged()); } test "Footer: context tokens absent until set, then shown alongside model" { var ft = Footer.init(testing.allocator); defer ft.deinit(); var buf: [32]u8 = undefined; // Absent until usage is reported. try testing.expectEqualStrings("", ft.contextText(&buf)); try ft.setModel("m"); { const lines = try ft.comp().render(80, testing.allocator); try testing.expect(std.mem.indexOf(u8, lines[0], "ctx") == null); } // Small count rendered verbatim. ft.setContextTokens(845); try testing.expectEqualStrings("845 ctx", ft.contextText(&buf)); { const lines = try ft.comp().render(80, testing.allocator); try testing.expect(vw(lines[0]) <= 80); try testing.expect(std.mem.indexOf(u8, lines[0], "845 ctx") != null); // model still present alongside. try testing.expect(std.mem.indexOf(u8, lines[0], "m") != null); } } test "Footer: contextText formatting boundaries (0, 999, 1000 -> k)" { var ft = Footer.init(testing.allocator); defer ft.deinit(); var buf: [32]u8 = undefined; // Zero is a real measured value (not "absent") -> "0 ctx". ft.setContextTokens(0); try testing.expectEqualStrings("0 ctx", ft.contextText(&buf)); // 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. The compact form // strips trailing zeros, so "1.0k" becomes "1k". ft.setContextTokens(1000); try testing.expectEqualStrings("1k ctx", ft.contextText(&buf)); } test "Footer: context window renders a fraction; unknown window stays raw" { var ft = Footer.init(testing.allocator); defer ft.deinit(); var buf: [32]u8 = undefined; ft.setContextTokens(12345); try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); ft.setContextWindow(200_000); try testing.expectEqualStrings("12.3k/200k ctx", ft.contextText(&buf)); // Window cleared (e.g. switch to a model without context_window). ft.setContextWindow(null); try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); } test "Footer: session id and hint segments render and clear" { var ft = Footer.init(testing.allocator); defer ft.deinit(); try ft.setModel("m"); try ft.setSessionId("0197c2a4-ffff"); try ft.setHint("esc interrupt"); { const lines = try ft.comp().render(80, testing.allocator); try testing.expect(std.mem.indexOf(u8, lines[0], "#0197c2a4") != null); try testing.expect(std.mem.indexOf(u8, lines[0], "esc interrupt") != null); } try ft.setHint(""); { const lines = try ft.comp().render(80, testing.allocator); try testing.expect(std.mem.indexOf(u8, lines[0], "esc interrupt") == null); } } test "Footer: large context token counts format as k; latest wins" { var ft = Footer.init(testing.allocator); defer ft.deinit(); var buf: [32]u8 = undefined; ft.setContextTokens(12345); try testing.expectEqualStrings("12.3k ctx", ft.contextText(&buf)); // Overwritten (latest-wins), not accumulated. ft.setContextTokens(2000); try testing.expectEqualStrings("2k ctx", ft.contextText(&buf)); } test "Footer: setContextTokens dirties; stable re-render is clean" { var ft = Footer.init(testing.allocator); defer ft.deinit(); try ft.setModel("m"); ft.setContextTokens(1000); _ = try ft.comp().render(80, testing.allocator); _ = try ft.comp().render(80, testing.allocator); try testing.expectEqual(@as(?usize, null), ft.comp().firstLineChanged()); ft.setContextTokens(2000); 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{ .{ .label = "anthropic:sonnet", .detail = "claude-sonnet-4 effort:high" }, .{ .label = "anthropic:opus", .detail = "claude-opus-4" }, .{ .label = "openai:gpt", .detail = "gpt-4o reasoning:medium" }, }; fn selKey(c: u21, text: []const u8) Key { return .{ .code = .{ .char = c }, .text = text }; } test "Selector: empty filter matches all; selectLabel preselects" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); try s.selectLabel("openai:gpt"); try testing.expectEqual(@as(usize, 2), s.selected); try testing.expectEqual(@as(?usize, 2), s.selectedIndex()); const lines = try s.comp().render(80, testing.allocator); // title + filter + 3 items. try testing.expectEqual(@as(usize, 5), lines.len); for (lines) |ln| try testing.expect(vw(ln) <= 80); } test "Selector: fuzzy typeahead filters; selection clamps" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); // Type "ops" -> subsequence of "anthropic:opus" (o,p,u,s contains o,p,s? ) // Use "gpt" which only matches openai:gpt. for ("gpt") |c| _ = try s.applyKey(selKey(c, &[_]u8{c})); try testing.expectEqual(@as(usize, 1), s.filtered.items.len); try testing.expectEqual(@as(?usize, 2), s.selectedIndex()); // openai:gpt // Backspace clears one char -> "gp" still only openai:gpt. _ = try s.applyKey(.{ .code = .backspace }); try testing.expectEqual(@as(usize, 1), s.filtered.items.len); } fn selCtrl(letter: u8) Key { return .{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }; } test "Selector: Ctrl+U clears the whole filter" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); for ("anth") |c| _ = try s.applyKey(selKey(c, &[_]u8{c})); try testing.expectEqual(@as(usize, 2), s.filtered.items.len); // both anthropic _ = try s.applyKey(selCtrl('u')); try testing.expectEqual(@as(usize, 0), s.filter.items.len); // Empty filter matches everything again. try testing.expectEqual(@as(usize, 3), s.filtered.items.len); } test "Selector: Ctrl+W deletes the trailing word" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); for ("foo bar") |c| _ = try s.applyKey(selKey(c, &[_]u8{c})); _ = try s.applyKey(selCtrl('w')); try testing.expectEqualStrings("foo ", s.filter.items); // A second Ctrl+W eats the space and the remaining word. _ = try s.applyKey(selCtrl('w')); try testing.expectEqualStrings("", s.filter.items); } test "Selector: navigation wraps and respects filtered order" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); _ = try s.applyKey(.{ .code = .{ .char = 'n' }, .mods = .{ .ctrl = true } }); // ctrl+n down try testing.expectEqual(@as(usize, 1), s.selected); _ = try s.applyKey(.{ .code = .down }); try testing.expectEqual(@as(usize, 2), s.selected); _ = try s.applyKey(.{ .code = .down }); // wrap to 0 try testing.expectEqual(@as(usize, 0), s.selected); _ = try s.applyKey(.{ .code = .{ .char = 'p' }, .mods = .{ .ctrl = true } }); // ctrl+p up -> wrap to bottom try testing.expectEqual(@as(usize, 2), s.selected); } test "Selector: Enter accepts, Escape cancels" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); try testing.expectEqual(SelectorAction.accept, try s.applyKey(.{ .code = .enter })); try testing.expectEqual(SelectorAction.cancel, try s.applyKey(.{ .code = .escape })); } test "Selector: no matches yields a placeholder and null selection" { var s = Selector.init(testing.allocator, "model", &sel_items); defer s.deinit(); for ("zzzz") |c| _ = try s.applyKey(selKey(c, &[_]u8{c})); try testing.expectEqual(@as(usize, 0), s.filtered.items.len); try testing.expectEqual(@as(?usize, null), s.selectedIndex()); // Enter on an empty list is a no-op (not accept). try testing.expectEqual(SelectorAction.none, try s.applyKey(.{ .code = .enter })); const lines = try s.comp().render(40, testing.allocator); try testing.expect(std.mem.indexOf(u8, lines[lines.len - 1], "no matches") != null); } // -- Integration with the real Engine (no TTY) ------------------------------ test "components drive the real engine without a TTY" { var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = engine.Engine.init(testing.allocator, &buf.writer, 40, 24, false); defer eng.deinit(); var user = UserText.init(testing.allocator); defer user.deinit(); var assistant = AssistantText.init(testing.allocator); defer assistant.deinit(); var ib = InputBox.init(testing.allocator); defer ib.deinit(); var footer = Footer.init(testing.allocator); 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")); try footer.setModel("m"); try eng.addComponent(user.comp()); try eng.addComponent(assistant.comp()); try eng.addComponent(ib.comp()); try eng.addComponent(footer.comp()); 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); } // -- Welcome / Thinking / CompactionSummary / ToolUse (P2) ------------------ test "Welcome: renders title + cwd, all within width" { var w = Welcome.init(testing.allocator); defer w.deinit(); try w.setVersion("0.1.0"); try w.setCwd("/tmp/project"); const lines = try w.comp().render(40, testing.allocator); // 2 content lines + 2 margin = 4. try testing.expectEqual(@as(usize, 4), lines.len); for (lines) |l| try testing.expect(vw(l) <= 40); try testing.expect(std.mem.indexOf(u8, lines[1], "panto v0.1.0") != null); try testing.expect(std.mem.indexOf(u8, lines[2], "/tmp/project") != null); } test "Welcome: title only when cwd unset" { var w = Welcome.init(testing.allocator); defer w.deinit(); const lines = try w.comp().render(20, testing.allocator); // 1 content line + 2 margin = 3. try testing.expectEqual(@as(usize, 3), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1], "panto") != null); } test "Welcome: honors the width contract at a tiny width" { var w = Welcome.init(testing.allocator); defer w.deinit(); try w.setVersion("0.1.0"); try w.setCwd("/a/very/long/working/directory/path/that/overflows"); // Width 6: every banner row (title + cwd) must truncate to fit. // 2 content lines + 2 margin = 4. const lines = try w.comp().render(6, testing.allocator); try testing.expectEqual(@as(usize, 4), lines.len); for (lines) |l| try testing.expect(vw(l) <= 6); } test "Thinking: streams dim, firstLineChanged stays near the tail" { var t = Thinking.init(testing.allocator); defer t.deinit(); try t.appendDelta("line one is fairly long so it wraps across"); _ = try t.comp().render(20, testing.allocator); // A clean re-render reports no change. _ = try t.comp().render(20, testing.allocator); try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); // Appending a delta should dirty near the tail (not line 0). try t.appendDelta(" more"); const flc = t.comp().firstLineChanged(); try testing.expect(flc != null and flc.? > 0); const lines = try t.comp().render(20, testing.allocator); for (lines) |l| try testing.expect(vw(l) <= 20); } test "CompactionSummary: header + wrapped summary within width" { var c = CompactionSummary.init(testing.allocator); defer c.deinit(); try c.setSummary("summarized prior turns here"); const lines = try c.comp().render(20, testing.allocator); // header + body lines + 2 margin = at least 4. try testing.expect(lines.len >= 4); // Content starts at lines[1] (lines[0] is the top margin). var found_compacted = false; for (lines) |l| if (std.mem.indexOf(u8, l, "compacted") != null) { found_compacted = true; break; }; try testing.expect(found_compacted); for (lines) |l| try testing.expect(vw(l) <= 20); } test "ToolUse: stage 1 renders tool (?) before the name resolves" { var t = ToolUse.init(testing.allocator); defer t.deinit(); const lines = try t.comp().render(40, testing.allocator); // margin + pad + header + pad + margin = 5 try testing.expectEqual(@as(usize, 5), lines.len); var found = false; for (lines) |l| if (std.mem.indexOf(u8, l, "tool (?)") != null) { found = true; break; }; try testing.expect(found); } 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+placeholder+pad+margin = 7 try testing.expect(lines.len >= 7); // 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 (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); } test "ToolUse: collapsed shows only the last collapsed_tail_lines output lines (default)" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("read"); try t.setInput("{}"); // 12 short output lines -> collapsed shows the marker + the last // collapsed_tail_lines (8) of them, eliding l1..l4. try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12"); const collapsed = try t.comp().render(40, testing.allocator); // margin+pad+header+sep+marker+(tail lines)+pad+margin // = 1+1+1+1+1+collapsed_tail_lines+1+1 = 7 + collapsed_tail_lines try testing.expectEqual(@as(usize, 7 + ToolUse.collapsed_tail_lines), collapsed.len); // Last output line (l12) is at lines[len-3] (before bottom pad + margin); // the first SHOWN tail line is at lines[len-3-(tail-1)]. // Tokens are rendered as " l<n>" followed by right-padding, so match on a // trailing space to avoid "l1" spuriously matching inside "l10".."l12". try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 3], "l12 ") != null); try testing.expect(std.mem.indexOf(u8, collapsed[collapsed.len - 3 - (ToolUse.collapsed_tail_lines - 1)], "l5 ") != null); // The earliest output lines (l1..l4) are elided when collapsed. var has_elided = false; for (collapsed) |l| { if (std.mem.indexOf(u8, l, "l1 ") != null or std.mem.indexOf(u8, l, "l2 ") != null or std.mem.indexOf(u8, l, "l3 ") != null or std.mem.indexOf(u8, l, "l4 ") != null) has_elided = true; } try testing.expect(!has_elided); // Expanding shows everything. t.setCollapsed(false); const expanded = try t.comp().render(40, testing.allocator); try testing.expect(expanded.len > collapsed.len); var has_l1_exp = false; for (expanded) |l| { if (std.mem.indexOf(u8, l, "l1 ") != null) has_l1_exp = true; } try testing.expect(has_l1_exp); } test "ToolUse: short output is shown whole even when collapsed" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("ls"); try t.setInput("{}"); try t.setOutput("only\ntwo"); const lines = try t.comp().render(40, testing.allocator); var seen_only = false; var seen_two = false; for (lines) |l| { if (std.mem.indexOf(u8, l, "only") != null) seen_only = true; if (std.mem.indexOf(u8, l, "two") != null) seen_two = true; } try testing.expect(seen_only and seen_two); } test "ToolUse: ANSI escapes are stripped from output on ingest" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("grep"); try t.setInput("{}"); // SGR colour around "match", an OSC title, and a charset-select escape. try t.setOutput("\x1b[1;31mmatch\x1b[0m here \x1b]0;title\x07\x1b(Bdone"); // The stored output must be free of escapes but keep the text. try testing.expectEqualStrings("match here done", t.output.?.items); } test "stripAnsi: handles CSI, OSC, plain text, and truncated escapes" { const a = testing.allocator; const cases = [_]struct { in: []const u8, want: []const u8 }{ .{ .in = "plain", .want = "plain" }, .{ .in = "\x1b[31mred\x1b[0m", .want = "red" }, .{ .in = "a\x1b[1mb\nc", .want = "ab\nc" }, .{ .in = "x\x1b]0;t\x07y", .want = "xy" }, .{ .in = "x\x1b]0;t\x1b\\y", .want = "xy" }, .{ .in = "trailing\x1b", .want = "trailing" }, .{ .in = "\x1b(Bplain", .want = "plain" }, }; for (cases) |c| { var out: std.ArrayList(u8) = .empty; defer out.deinit(a); try stripAnsi(c.in, &out, a); try testing.expectEqualStrings(c.want, out.items); } } test "ToolUse: collapse/expand is a length change with a cache-derived firstLineChanged" { // Expanding/collapsing changes the rendered LINE COUNT (plan §3.3). A // collapse toggle is a structural change (the whole output region shifts), // so `setCollapsed` re-dirties via `markDirty` — dropping the baseline — and // the post-render `firstLineChanged` is therefore 0 (cache-derived: a full // drop reports from the top). That is correct and cheap for a small tool // component; the engine's line-diff backstop (plan §3.3) still handles the // length delta. The KEY guarantees this test pins: the line COUNT changes // across the toggle, the signal is cache-derived (0 after a full drop, null // after a stable render), and there is no hand-managed drift. var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("read"); try t.setInput("{}"); // 12 output lines, so collapsing (tail = collapsed_tail_lines = 8) truncates. try t.setOutput("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12"); // Default collapsed: // margin+pad+header+sep+truncmark+8lines+pad+margin = 1+1+1+1+1+8+1+1 = 15 const collapsed = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(usize, 15), collapsed.len); // A stable re-render is clean: the live signal is null. _ = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); // Expand: margin+pad+header+sep+12lines+pad+margin = 1+1+1+1+12+1+1 = 18 t.setCollapsed(false); // While dirty (full drop), the signal is the cache-derived 0. try testing.expectEqual(@as(?usize, 0), t.comp().firstLineChanged()); const expanded = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(usize, 18), expanded.len); // After the render the cache is clean, so the live signal is null again // (the toggle's drop-from-0 diff is internal bookkeeping, not a live cut). try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); // Collapse again: shrink back to 15 rows. t.setCollapsed(true); const recollapsed = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(usize, 15), recollapsed.len); } test "ToolUse: streaming args keep the render baseline for differential updates" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("search"); try t.appendInput("{\"q\":"); _ = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); const old_len = t.cache.lines.?.len; try t.appendInput("\"term\"}"); // Args deltas are append-only, so the component must retain its old lines // as the diff baseline. Dropping the cache here made the engine cut from // the top of the tool on every streamed args chunk, repainting all content // below it and causing visible flicker in long transcripts. try testing.expect(t.cache.lines != null); try testing.expectEqual(old_len, t.cache.lines.?.len); try testing.expectEqual(@as(?usize, old_len - 1), t.comp().firstLineChanged()); _ = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); } test "ToolUse: final identical name/input does not dirty a clean tool" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("search"); try t.setInput("{\"q\":\"term\"}"); _ = try t.comp().render(40, testing.allocator); try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); try t.setName("search"); try t.setInput("{\"q\":\"term\"}"); try testing.expectEqual(@as(?usize, null), t.comp().firstLineChanged()); } test "ToolUse: args are rendered VERBATIM (no pretty-print) and within width" { // The input JSON must pass through byte-for-byte (no reflow of the JSON // structure), only terminal-wrapped. We use a compact object with no spaces // and assert the exact substring survives in the joined header. var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("search"); try t.appendInput("{\"q\":\"a b\","); try t.appendInput("\"n\":10}"); const verbatim = "{\"q\":\"a b\",\"n\":10}"; try testing.expectEqualStrings(verbatim, t.input.items); // Wide render: the verbatim args appear unmodified on the header line // (lines[2] = top-margin + top-pad + header). const wide = try t.comp().render(80, testing.allocator); try testing.expect(std.mem.indexOf(u8, wide[2], verbatim) != null); // Narrow render: header wraps across rows but every row honors the width // contract (no pretty-print expansion, just wrapping). const narrow = try t.comp().render(12, testing.allocator); for (narrow) |l| try testing.expect(vw(l) <= 12); } test "ToolUse: long output lines honor the width contract" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("read"); try t.setInput("{}"); t.setCollapsed(false); try t.setOutput("a very long single output line that must be wrapped to fit the narrow width"); const lines = try t.comp().render(10, testing.allocator); for (lines) |l| try testing.expect(vw(l) <= 10); } test "ToolUse: tabbed output prepaints background without expanding tabs" { var t = ToolUse.init(testing.allocator); defer t.deinit(); try t.setName("read"); try t.setInput("{}"); t.setCollapsed(false); try t.setOutput("\treturn err"); t.setResultOk(true); const lines = try t.comp().render(32, testing.allocator); var found = false; for (lines) |line| { if (std.mem.indexOf(u8, line, "\treturn err")) |_| { found = true; try testing.expect(std.mem.indexOfScalar(u8, line, '\r') != null); try testing.expect(vw(line) <= 32); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, line, "\t")); } } try testing.expect(found); }