diff options
| author | t <t@tjp.lol> | 2026-06-12 10:21:56 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-12 10:23:02 -0600 |
| commit | af8ba87475bfae9c1b4fa70c88fb4c59630a5670 (patch) | |
| tree | 8d0c09a92c6f3eaa805e092d830342d61c1a6198 /src/tui_components.zig | |
| parent | 3dcbf15bdb90e4c90e7225b82945a6862a7a063f (diff) | |
Replace Footer FPS display with model dimension and dim styling
Remove setFrameTime() method and fps-related rendering from Footer.
Replace with setModel() to display model name with dim styling. Update
all Footer tests accordingly, adjusting line counts and expectations for
new layout with top/bottom rules wrapping content. Remove reverse video
styling from footer.
Diffstat (limited to 'src/tui_components.zig')
| -rw-r--r-- | src/tui_components.zig | 178 |
1 files changed, 82 insertions, 96 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig index 1ce5b56..30dd60a 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -1018,10 +1018,38 @@ pub const InputBox = struct { // 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); - try self.cache.store(rows.items[window.start..window.end]); + + // 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); } + /// 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 @@ -1148,23 +1176,15 @@ pub const InputBox = struct { }; // =========================================================================== -// Footer — persistent bottom line with frame-timing element (plan §6) +// Footer — persistent bottom line (plan §6) // =========================================================================== -/// The persistent bottom line. For P1 it renders a FRAME-TIMING element: the -/// last frame's render time as a theoretical-max fps (1000/ms), shown inverted -/// (reverse-video). It optionally shows model info passed in by the app. The -/// fps element is TEMPORARY (removed after perf validation) but REQUIRED for -/// P1. -/// -/// Frame-time input: the app calls `setFrameTime(ms)` after each rendered frame -/// with the measured render duration in milliseconds; this updates the fps and -/// marks dirty so the footer repaints. `setModel(name)` sets the model info. +/// The persistent bottom line. Renders model info and the latest context-window +/// token count, styled as dim chrome. `setModel(name)` sets the model info; +/// `setContextTokens(n)` updates the context readout. pub const Footer = struct { alloc: std.mem.Allocator, cache: RenderCache, - /// Last frame's render time in milliseconds (null = not measured yet). - frame_ms: ?f64 = null, /// 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). @@ -1182,13 +1202,6 @@ pub const Footer = struct { self.cache.deinit(); } - /// Feed the last frame's render time (milliseconds). Marks dirty so the - /// footer's fps element repaints next frame. - pub fn setFrameTime(self: *Footer, ms: f64) void { - self.frame_ms = ms; - self.cache.markDirty(); - } - /// Set the model info shown in the footer. pub fn setModel(self: *Footer, name: []const u8) !void { self.model.clearRetainingCapacity(); @@ -1214,48 +1227,28 @@ pub const Footer = struct { return std.fmt.bufPrint(buf, "{d:.1}k ctx", .{k}) catch ""; } - /// Format the theoretical-max fps element from the last frame time. - /// `fps = 1000 / ms`; a zero/sub-millisecond frame is reported as a capped - /// ">9999" sentinel rather than infinity. "--" when unmeasured. - fn fpsText(self: *const Footer, buf: []u8) []const u8 { - const ms = self.frame_ms orelse return std.fmt.bufPrint(buf, "fps: --", .{}) catch "fps: --"; - if (ms <= 0.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999"; - const fps = 1000.0 / ms; - if (fps > 9999.0) return std.fmt.bufPrint(buf, "fps: >9999", .{}) catch "fps: >9999"; - return std.fmt.bufPrint(buf, "fps: {d:.0} ({d:.2}ms)", .{ fps, ms }) catch "fps: ?"; - } - 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; - var fps_buf: [48]u8 = undefined; - const fps = self.fpsText(&fps_buf); var ctx_buf: [32]u8 = undefined; const ctx = self.contextText(&ctx_buf); - // Build the PLAIN content: "<model> <ctx> <fps>" (model and ctx - // only when present). + // Build the PLAIN content: "<model> <ctx>" (each only when present). var plain: std.ArrayList(u8) = .empty; defer plain.deinit(a); if (self.model.items.len != 0) { try plain.appendSlice(a, self.model.items); - try plain.appendSlice(a, " "); - } - if (ctx.len != 0) { - try plain.appendSlice(a, ctx); - try plain.appendSlice(a, " "); + if (ctx.len != 0) try plain.appendSlice(a, " "); } - try plain.appendSlice(a, fps); + if (ctx.len != 0) try plain.appendSlice(a, ctx); const vis = truncateToCols(plain.items, width); - // The fps element is shown INVERTED (reverse video). The whole footer - // line uses reverse video so the timing element stands out; the model - // rides along in the same inverted run. (Temporary perf chrome.) - const cursor_style = theme.default.fg(.cursor); // reverse video - const composed = try std.fmt.allocPrint(a, "{s}{s}{s}", .{ cursor_style.open(), vis, cursor_style.close() }); + // 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}; @@ -1915,12 +1908,13 @@ test "InputBox: insert printable chars and render with cursor block when focused try ib.applyKey(charKey('h', "h")); try ib.applyKey(charKey('i', "i")); const lines = try ib.comp().render(20, testing.allocator); - try testing.expectEqual(@as(usize, 1), lines.len); - try testing.expect(vw(lines[0]) <= 20); - // Focused => emits CURSOR_MARKER and reverse-video style. - try testing.expect(std.mem.indexOf(u8, lines[0], CURSOR_MARKER) != null); - try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null); - try testing.expect(std.mem.indexOf(u8, lines[0], "hi") != null); + // 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" { @@ -1928,7 +1922,7 @@ test "InputBox: not focused emits no cursor marker" { 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[0], CURSOR_MARKER) == null); + try testing.expect(std.mem.indexOf(u8, lines[1], CURSOR_MARKER) == null); } test "InputBox: backspace, delete, and cursor movement" { @@ -1967,7 +1961,8 @@ test "InputBox: shift+enter inserts newline, enter submits" { 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); - try testing.expectEqual(@as(usize, 2), lines.len); + // 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 }); @@ -2017,7 +2012,7 @@ test "InputBox: cursor block fits within width at end of a full line" { 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); - try testing.expect(vw(lines[0]) <= 5); + try testing.expect(vw(lines[1]) <= 5); } fn ctrlKey(letter: u8) Key { @@ -2233,10 +2228,11 @@ test "InputBox: line cap renders only the last cap rows by default" { try typeStr(&ib, &b); } const lines = try ib.comp().render(20, testing.allocator); - // Only `cap` rows rendered (the tail window: L2, L3, L4). - try testing.expectEqual(@as(usize, 3), lines.len); - try testing.expect(std.mem.indexOf(u8, lines[0], "L2") != null); - try testing.expect(std.mem.indexOf(u8, lines[2], "L4") != null); + // 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); } @@ -2255,14 +2251,15 @@ test "InputBox: scroll-window slides up to keep the cursor visible" { // to byte 0 so cursor_row == 0, above the default tail window. ib.moveHome(); const lines = try ib.comp().render(20, testing.allocator); - try testing.expectEqual(@as(usize, 3), lines.len); - // Window slid up so the cursor row (L0) is visible at the TOP: the cursor - // block + marker render on row 0 (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[0], CURSOR_MARKER) != null); - try testing.expect(std.mem.indexOf(u8, lines[1], "L1") != null); - try testing.expect(std.mem.indexOf(u8, lines[2], "L2") != null); + // 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" { @@ -2271,7 +2268,8 @@ test "InputBox: single-row default is unaffected by the cap" { 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); - try testing.expectEqual(@as(usize, 1), lines.len); + // One content row + top/bottom rules. + try testing.expectEqual(@as(usize, 3), lines.len); } test "InputBox: setBuffer/buffer round-trip for the $EDITOR hook" { @@ -2286,57 +2284,45 @@ test "InputBox: setBuffer/buffer round-trip for the $EDITOR hook" { // -- Footer ----------------------------------------------------------------- -test "Footer: renders fps from frame time, inverted, within width" { +test "Footer: renders model dim, within width, no reverse video" { var ft = Footer.init(testing.allocator); defer ft.deinit(); - ft.setFrameTime(8.0); // 1000/8 = 125 fps + 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); - // Inverted (reverse video) styling present. - try testing.expect(std.mem.indexOf(u8, lines[0], "\x1b[7m") != null); - // fps value 125 present. - try testing.expect(std.mem.indexOf(u8, lines[0], "125") != null); -} - -test "Footer: unmeasured frame shows placeholder; submillisecond capped" { - var ft = Footer.init(testing.allocator); - defer ft.deinit(); - var buf: [48]u8 = undefined; - try testing.expectEqualStrings("fps: --", ft.fpsText(&buf)); - ft.setFrameTime(0.0); - try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf)); - ft.setFrameTime(0.05); // 20000 fps -> capped - try testing.expectEqualStrings("fps: >9999", ft.fpsText(&buf)); + // 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(); - ft.setFrameTime(10.0); try ft.setModel("gpt-test-model"); const lines = try ft.comp().render(12, testing.allocator); try testing.expect(vw(lines[0]) <= 12); } -test "Footer: setFrameTime dirties; stable re-render is clean" { +test "Footer: setModel dirties; stable re-render is clean" { var ft = Footer.init(testing.allocator); defer ft.deinit(); - ft.setFrameTime(8.0); + 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()); - ft.setFrameTime(16.0); + try ft.setModel("m2"); try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged()); } -test "Footer: context tokens absent until set, then shown alongside fps" { +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)); - ft.setFrameTime(8.0); + try ft.setModel("m"); { const lines = try ft.comp().render(80, testing.allocator); try testing.expect(std.mem.indexOf(u8, lines[0], "ctx") == null); @@ -2348,8 +2334,8 @@ test "Footer: context tokens absent until set, then shown alongside fps" { 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); - // fps element still present alongside. - try testing.expect(std.mem.indexOf(u8, lines[0], "fps:") != null); + // model still present alongside. + try testing.expect(std.mem.indexOf(u8, lines[0], "m") != null); } } @@ -2382,7 +2368,7 @@ test "Footer: large context token counts format as k; latest wins" { test "Footer: setContextTokens dirties; stable re-render is clean" { var ft = Footer.init(testing.allocator); defer ft.deinit(); - ft.setFrameTime(8.0); + try ft.setModel("m"); ft.setContextTokens(1000); _ = try ft.comp().render(80, testing.allocator); _ = try ft.comp().render(80, testing.allocator); @@ -2412,7 +2398,7 @@ test "components drive the real engine without a TTY" { try assistant.appendDelta("hello"); ib.setFocused(true); try ib.applyKey(charKey('q', "q")); - footer.setFrameTime(8.0); + try footer.setModel("m"); try eng.addComponent(user.comp()); try eng.addComponent(assistant.comp()); @@ -2429,7 +2415,7 @@ test "components drive the real engine without a TTY" { // Stream another delta -> only the assistant should re-render; the engine // stays on the differential path (no full clear after first paint). try assistant.appendDelta(" world"); - footer.setFrameTime(9.0); + try footer.setModel("m2"); buf.clearRetainingCapacity(); try eng.render(); const out2 = buf.written(); |
