summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-12 10:38:28 -0600
committert <t@tjp.lol>2026-06-12 11:06:18 -0600
commit7343898524f9f692620f4cb276f56cfde30f6269 (patch)
tree8cf0621d33c4ae7f292c95f4684e1e9590175ae8 /src/tui_components.zig
parentaf8ba87475bfae9c1b4fa70c88fb4c59630a5670 (diff)
fuzzy selectors for models and reasoning levels
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig449
1 files changed, 449 insertions, 0 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig
index 30dd60a..e64b5bd 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -1278,6 +1278,360 @@ pub const Footer = struct {
};
// ===========================================================================
+// 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;
+ }
+ }
+ }
+
+ /// 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 <key>`
+ // 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: "<title> (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 (plan §6: "version, cwd, model info")
// ===========================================================================
@@ -2377,6 +2731,101 @@ test "Footer: setContextTokens dirties; stable re-render is clean" {
try testing.expectEqual(@as(?usize, 0), ft.comp().firstLineChanged());
}
+// -- 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" {