fc944d79e97e
fix search keys colliding with typing: letters in a package name (the i in 'ripgrep', t in 'git') were triggering install/try/etc. Now every printable key types into the query; only Enter (select), Tab (detail), arrows (nav), Esc (cancel) act. Enter returns the highlighted package and the shared selectAndInstall() prompt (i/s/t) runs after the widget's raw mode is gone — no collision. Also dupe the chosen package into the caller's allocator (search arena deinits on return; old code returned dangling slices). Removed now-unused copyToClipboard + SearchResult; controls now '[enter] install [tab] info [up/down] nav [esc] cancel' — build+tests green
- time
- 2026-06-05 03:58 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- nina finishing brief
- parent
- 5ea64eb84989
↓ nina-fc944d79.tar.gzatlas at this save
files changed3 files
src/commands.zig+14 −15src/search.zig+27 −52src/types.zig+0 −5src/commands.zig+14 −15
@@ -537,10 +537,8 @@537537 pub fn searchCmd(ctx: Ctx) !void {538538 const query = if (ctx.args.len > 0) ctx.args[0] else "";539539 api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);540- const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);541- if (result) |r| {542- installFromResult(ctx, r) catch |e| return e;543- }540+ const selected = try search_tui.run(ctx.gpa, ctx.io, query, .packages);541+ if (selected) |npkg| try selectAndInstall(ctx, npkg);544542 }545543 546544 pub fn optionCmd(ctx: Ctx) !void {@@ -577,8 +575,16 @@577575 break;578576 }579577 }580- output.installSelectPrompt(found.pname, found.version);578+ try selectAndInstall(ctx, found);579+}581580 581+// Prompt for the install method (profile / system / try) and dispatch. Shared by582+// `nina install <pkg>` and the search widget's Enter-to-select — the letter keys583+// are read here, after the search widget's raw mode is gone, so they don't584+// collide with typing a query.585+fn selectAndInstall(ctx: Ctx, npkg: types.NixPackage) !void {586+ output.installSelectPrompt(npkg.pname, npkg.version);587+582588 var choice_buf: [8]u8 = undefined;583589 var choice_len: usize = 0;584590 while (choice_len < choice_buf.len) {@@ -590,18 +596,11 @@590596 const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");591597 592598 if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {593- try profileInstallPkg(ctx, found);599+ try profileInstallPkg(ctx, npkg);594600 } else if (choice[0] == 's' or choice[0] == 'S') {595- try systemInstallPkg(ctx, found);601+ try systemInstallPkg(ctx, npkg);596602 } else if (choice[0] == 't' or choice[0] == 'T') {597- try tryPkg(ctx, found);598- }599-}600-601-fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {602- switch (result.action) {603- .profile => try profileInstallPkg(ctx, result.package),604- .system => try systemInstallPkg(ctx, result.package),603+ try tryPkg(ctx, npkg);605604 }606605 }607606 src/search.zig+27 −52
@@ -38,7 +38,7 @@3838 io: std.Io,3939 initial_query: []const u8,4040 mode: Mode,41-) !?types.SearchResult {41+) !?types.NixPackage {4242 // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,4343 // CI), print plain results and return instead of the raw-mode widget, which4444 // needs a real tty and would block waiting for keys.@@ -80,7 +80,7 @@8080 // stamps the time; the nix search fires only once typing pauses for8181 // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so8282 // there is no blank flash mid-typing.83- var chosen: ?types.SearchResult = null;83+ var chosen: ?types.NixPackage = null;8484 var query_dirty = false;8585 var query_changed_at = std.Io.Clock.awake.now(io);8686 loop: while (true) {@@ -118,6 +118,10 @@118118 const n = std.posix.read(STDIN_FD, &buf) catch break;119119 if (n == 0) break;120120 121+ // The query is a live text field, so every printable key types into it —122+ // single-letter actions would collide with package names (the 'i' in123+ // "ripgrep" must not trigger install). Only non-text keys act: Enter124+ // selects, Tab expands detail, arrows navigate, Esc cancels.121125 if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {122126 switch (buf[2]) {123127 'A' => if (selection > 0) { selection -= 1; }, // up@@ -125,36 +129,15 @@125129 else => {},126130 }127131 } else switch (buf[0]) {128- 'k' => if (selection > 0) { selection -= 1; },129- 'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },130- 0x1b, 'q' => break :loop,132+ 0x1b => break :loop, // Esc: cancel (chosen stays null)131133 '\t' => expanded = !expanded, // Tab: toggle package detail132- 'i', '\r' => {134+ '\r', '\n' => { // Enter: select the highlighted package133135 if (mode == .packages and packages.len > selection) {134- chosen = .{ .package = packages[selection], .action = .profile };136+ chosen = packages[selection];135137 }136138 break :loop;137139 },138- 's' => {139- if (mode == .packages and packages.len > selection) {140- chosen = .{ .package = packages[selection], .action = .system };141- }142- break :loop;143- },144- 't' => {145- if (mode == .packages and packages.len > selection) {146- chosen = .{ .package = packages[selection], .action = .profile };147- // flag: try mode — caller handles148- }149- break :loop;150- },151- 'c' => {152- // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)153- if (mode == .packages and packages.len > selection) {154- copyToClipboard(gpa, io, packages[selection].attr);155- }156- },157- 127 => { // backspace140+ 127, 8 => { // backspace158141 if (query_len > 0) {159142 query_len -= 1;160143 query_dirty = true;@@ -207,9 +190,21 @@207190 output.cursorUp(WIDGET_LINES);208191 output.flush();209192 210- return chosen;193+ // The chosen package's strings live in the search arena, which deinits as this194+ // function returns — dupe into the caller's allocator so it survives.195+ if (chosen) |pkg| return dupePackage(gpa, pkg);196+ return null;211197 }212198 199+fn dupePackage(gpa: std.mem.Allocator, pkg: types.NixPackage) types.NixPackage {200+ return .{201+ .attr = gpa.dupe(u8, pkg.attr) catch "",202+ .pname = gpa.dupe(u8, pkg.pname) catch "",203+ .version = gpa.dupe(u8, pkg.version) catch "",204+ .description = gpa.dupe(u8, pkg.description) catch "",205+ };206+}207+213208 pub const Mode = enum { packages, options };214209 215210 fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {@@ -241,7 +236,7 @@241236 242237 // Plain, non-interactive rendering for non-tty stdin. Same pink :: header and243238 // result count the widget shows, then one line per result; no cursor control.244-fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.SearchResult {239+fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.NixPackage {245240 // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.246241 if (std.mem.trim(u8, query, " \t").len == 0) {247242 output.searchEmptyQuery();@@ -398,36 +393,16 @@398393 // Lines 14-17: controls + padding to reach WIDGET_LINES399394 output.raw(CLR);400395 if (mode == .packages) {401- output.p(" {s} profile {s} system {s} try {s} copy {s} info {s} exit\n", .{402- keyHint("i"), keyHint("s"), keyHint("t"), keyHint("c"), keyHint("tab"), keyHint("esc"),396+ output.p(" {s} install {s} info {s}[up/down]{s} nav {s} cancel\n", .{397+ keyHint("enter"), keyHint("tab"), DIM, RST, keyHint("esc"),403398 });404399 } else {405- output.p(" {s} insert {s}[up/down]{s} nav {s} exit\n", .{ keyHint("i"), DIM, RST, keyHint("esc") });400+ output.p(" {s}[up/down]{s} nav {s} cancel\n", .{ DIM, RST, keyHint("esc") });406401 }407402 // Padding to reach WIDGET_LINES (18)408403 var pad: usize = 14;409404 while (pad < WIDGET_LINES) : (pad += 1) {410405 output.raw(CLR ++ "\n");411- }412-}413-414-fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {415- _ = gpa;416- // Try common clipboard tools417- const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };418- for (tools) |tool| {419- var child = std.process.spawn(io, .{420- .argv = &.{tool},421- .stdin = .pipe,422- .stdout = .ignore,423- .stderr = .ignore,424- }) catch continue;425- if (child.stdin) |stdin| {426- stdin.writeStreamingAll(io, text) catch {};427- stdin.close(io);428- }429- _ = child.wait(io) catch {};430- return;431406 }432407 }433408 src/types.zig+0 −5
@@ -84,11 +84,6 @@8484 8585 pub const InstallPath = enum { profile, system };8686 87-pub const SearchResult = struct {88- package: NixPackage,89- action: InstallPath,90-};91-9287 test "types are defined" {9388 const p = NixPackage{9489 .attr = "firefox",