summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-12 10:21:56 -0600
committert <t@tjp.lol>2026-06-12 10:23:02 -0600
commitaf8ba87475bfae9c1b4fa70c88fb4c59630a5670 (patch)
tree8d0c09a92c6f3eaa805e092d830342d61c1a6198
parent3dcbf15bdb90e4c90e7225b82945a6862a7a063f (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.
-rw-r--r--src/tui_app.zig19
-rw-r--r--src/tui_components.zig178
2 files changed, 84 insertions, 113 deletions
diff --git a/src/tui_app.zig b/src/tui_app.zig
index 81894fa..5a9b7f4 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -7,7 +7,7 @@
//! - a `tui_engine.Engine` driving a LIST of components,
//! - the transcript (heap-allocated user/assistant/status components that
//! persist for the engine to borrow),
-//! - a pinned `InputBox` (focused) and `Footer` (fps element),
+//! - a pinned `InputBox` (focused) and `Footer`,
//! - the libpanto stream pump that routes each `Event` to component state.
//!
//! ## No "active component" invariant (plan §6)
@@ -728,21 +728,12 @@ pub const App = struct {
// -- the render pump ----------------------------------------------------
- /// Render a frame if one is pending, feeding the footer the measured
- /// render time. Returns true if a frame was drawn.
+ /// Render a frame if one is pending. Returns true if a frame was drawn.
pub fn maybeRender(self: *App) !bool {
const now = self.clock.now();
if (!self.scheduler.shouldRenderNow(now)) return false;
- const start = self.clock.now();
try self.engine.render();
self.flushSink();
- const end = self.clock.now();
- const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms);
- // Feed the footer the last frame's render time. This dirties the
- // footer for NEXT frame; we don't recursively render here (the next
- // pending frame picks it up), keeping the fps readout one frame
- // behind, which is acceptable for the perf-validation surface.
- self.footer.setFrameTime(ms);
self.scheduler.noteRendered(self.clock.now());
return true;
}
@@ -751,12 +742,8 @@ pub const App = struct {
/// the coalescing window.
pub fn renderNow(self: *App) !void {
self.scheduler.requestRender();
- const start = self.clock.now();
try self.engine.render();
self.flushSink();
- const end = self.clock.now();
- const ms = @as(f64, @floatFromInt(end - start)) / @as(f64, std.time.ns_per_ms);
- self.footer.setFrameTime(ms);
self.scheduler.noteRendered(self.clock.now());
}
@@ -1620,8 +1607,6 @@ test "maybeRender feeds the footer a frame time and respects coalescing" {
h.app.scheduler.requestRender();
try testing.expect(try h.app.maybeRender()); // idle => renders
- // Footer received a frame time (>= 0).
- try testing.expect(h.app.footer.frame_ms != null);
}
test "routeEvent: tool result correlates to its ToolUse component by id" {
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();