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