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