summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig245
1 files changed, 238 insertions, 7 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig
index 6f298c3..b93c30b 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -870,6 +870,14 @@ pub const InputBox = struct {
/// 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
@@ -891,6 +899,9 @@ pub const InputBox = struct {
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();
}
@@ -928,17 +939,21 @@ pub const InputBox = struct {
}
/// Replace the whole editor buffer (e.g. with text edited in `$EDITOR`).
- /// Places the cursor at the end and marks dirty.
+ /// 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) ------------------
- fn insertText(self: *InputBox, bytes: []const u8) !void {
+ /// 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();
@@ -1101,11 +1116,77 @@ pub const InputBox = struct {
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;
@@ -1204,7 +1285,11 @@ pub const InputBox = struct {
.right => if (k.mods.alt or k.mods.ctrl) self.moveWordRight() else self.moveRight(),
.home => self.moveHome(),
.end => self.moveEnd(),
- else => {}, // tab, arrows up/down, fkeys: ignored
+ // 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
}
}
@@ -1540,6 +1625,15 @@ pub const Footer = struct {
/// 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
@@ -1558,6 +1652,8 @@ pub const Footer = struct {
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();
}
@@ -1570,12 +1666,34 @@ pub const Footer = struct {
/// 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) and marks dirty so the footer repaints.
- pub fn setContextTokens(self: *Footer, tokens: u64) void {
+ /// 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
@@ -1601,7 +1719,13 @@ pub const Footer = struct {
/// 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 "";
- return formatTokenShort(buf, n, " ctx");
+ 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",
@@ -1646,7 +1770,7 @@ pub const Footer = struct {
// leading and trailing separators are conditional on the next
// segment being non-empty.
//
- // <model> <ctx> <session tokens> <session cost>
+ // <model> <ctx> <session tokens> <session cost> <#sid> <hint>
var plain: std.ArrayList(u8) = .empty;
defer plain.deinit(a);
if (self.model.items.len != 0) try plain.appendSlice(a, self.model.items);
@@ -1662,6 +1786,15 @@ pub const Footer = struct {
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);
@@ -1776,6 +1909,15 @@ pub const Selector = struct {
}
}
+ /// 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 {
@@ -3140,6 +3282,64 @@ test "InputBox: setBuffer/buffer round-trip for the $EDITOR hook" {
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" {
@@ -3213,6 +3413,37 @@ test "Footer: contextText formatting boundaries (0, 999, 1000 -> k)" {
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();