Files
browser/src/agent/picker.zig
T
Karl Seguin 8e42d63c1c zig: Zig 0.16
Built against https://github.com/lightpanda-io/zig-v8-fork/tree/zig-0.16 but
it doesn't require a new v8 build.

Built against https://github.com/lightpanda-io/boringssl-zig/tree/zig-0.16
since the current fork we point to isn't updated.

A global std.Io instance, lp.io. Way easier this way and requires 0 changes to
our libcurl integration / event loop.

Network code uses a new layer that does what Zig 0.15's posix package used to
do. Again, quicker migration that way. But, as long as we have the global IO,
and given the half-baked nature of networking in std.Io 0.16, this just makes
sense. Things can be migrated as needed.

The std.time.* -> std.Io.Timestamp/Clock/Duration resulted in _a lot_ of
changes. ArrayList = .{} -> ArrayList -> .empty also resulted in a lot of
changes, but that's obviously superficial. As is the trimLeft/trimRight ->
trimStart/trimEnd rename.

Locking adopt the `Uncancelable` variants, e.g. mutex.lockUncancelable() to
preserve the error-free signature (and, because cancellation would be something
we'd have to put more thought into).

std.json.ObjectMap is now unmanaged, so the allocator had to be passed along.
However, there's still a deprecated managed variant of MemoryPool, so I switched
to it (we can do a small follow up PR to move to the unmanaged after).

I tried use_llvm = false, but it locks my computer, consuming RAM until MacOS
gives me a popup I've never seen before, begging me to start killing processes.

Agent and the networking stuff saw the most significant changes.
2026-07-22 13:26:03 +08:00

266 lines
11 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! Interactive numbered-choice picker for stdin/stderr prompts (provider
//! selection, /save mode, …). Self-contained raw-terminal handling; runs
//! before — or without — the isocline REPL.
const std = @import("std");
const lp = @import("lightpanda");
const ansi = @import("ansi.zig");
pub fn interactiveTty() bool {
const stdin_tty = std.Io.File.stdin().isTty(lp.io) catch false;
const stderr_tty = std.Io.File.stderr().isTty(lp.io) catch false;
return stdin_tty and stderr_tty;
}
/// Numbered TTY picker. `default` (if set) marks that row "(default)" and
/// makes Enter start on that index. Up/Down moves the active row; Enter
/// selects it. Numbered input still works for users who prefer typing.
pub fn promptNumberedChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
if (items.len == 0) return error.NoChoice;
const valid_default: ?usize = if (default) |d| if (d < items.len) d else null else null;
if (interactiveTty()) {
return promptInteractiveChoice(header, items, valid_default) catch |err| switch (err) {
error.NotInteractive => try promptNumberedChoiceLine(header, items, valid_default),
else => err,
};
}
return promptNumberedChoiceLine(header, items, valid_default);
}
/// Line-oriented fallback. Errors with NoChoice after 3 invalid attempts.
fn promptNumberedChoiceLine(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
var stdin_buf: [128]u8 = undefined;
var stdin = std.Io.File.stdin().readerStreaming(lp.io, &stdin_buf);
var attempt: u8 = 0;
while (attempt < 3) : (attempt += 1) {
std.debug.print("{s}\n", .{header});
for (items, 0..) |item, idx| {
const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker });
}
std.debug.print("> ", .{});
const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled,
};
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (trimmed.len == 0) {
if (default) |d| return d;
std.debug.print("Invalid input — type a number.\n", .{});
continue;
}
const choice = std.fmt.parseInt(usize, trimmed, 10) catch {
const hint: []const u8 = if (default != null) " (or press Enter for default)" else "";
std.debug.print("Invalid input — type a number{s}.\n", .{hint});
continue;
};
if (choice >= 1 and choice <= items.len) return choice - 1;
std.debug.print("Out of range.\n", .{});
}
return error.NoChoice;
}
const ChoiceInput = enum { up, down, enter, cancel, ignore };
const ChoiceState = struct {
selected: usize,
fn init(default: ?usize) ChoiceState {
return .{ .selected = default orelse 0 };
}
fn apply(self: *ChoiceState, input: ChoiceInput, item_count: usize) ?usize {
switch (input) {
.up => self.selected = if (self.selected == 0) item_count - 1 else self.selected - 1,
.down => self.selected = (self.selected + 1) % item_count,
.enter => return self.selected,
.cancel, .ignore => {},
}
return null;
}
};
const RawTerminal = struct {
original: std.posix.termios,
fn enable() error{NotInteractive}!RawTerminal {
if (!interactiveTty()) return error.NotInteractive;
// A tty that refuses raw mode is non-interactive for our purposes.
const original = std.posix.tcgetattr(std.posix.STDIN_FILENO) catch return error.NotInteractive;
var raw = original;
raw.iflag.BRKINT = false;
raw.iflag.ICRNL = false;
raw.iflag.INPCK = false;
raw.iflag.ISTRIP = false;
raw.iflag.IXON = false;
raw.oflag.OPOST = false;
raw.cflag.CSIZE = .CS8;
raw.lflag.ECHO = false;
raw.lflag.ICANON = false;
raw.lflag.IEXTEN = false;
raw.lflag.ISIG = false;
raw.cc[@intFromEnum(std.c.V.MIN)] = 0;
raw.cc[@intFromEnum(std.c.V.TIME)] = 1;
std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, raw) catch return error.NotInteractive;
// Under `ansi.kitty_disambiguate` (pushed by `Terminal.readLine`),
// cursor keys arrive as CSI-u the byte reader can't parse; push the
// legacy encoding to force plain arrows. restore() pops back to
// whatever the REPL had pushed.
_ = std.c.write(std.posix.STDOUT_FILENO, ansi.kitty_legacy.ptr, ansi.kitty_legacy.len);
return .{ .original = original };
}
fn restore(self: *const RawTerminal) void {
_ = std.c.write(std.posix.STDOUT_FILENO, ansi.kitty_pop.ptr, ansi.kitty_pop.len);
std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, self.original) catch {};
}
};
fn promptInteractiveChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
var raw: RawTerminal = try .enable();
defer raw.restore();
var state: ChoiceState = .init(default);
const line_count = items.len + 2;
var first_render = true;
while (true) {
renderChoice(header, items, default, state.selected, first_render);
first_render = false;
const input = readChoiceInput() catch return error.UserCancelled;
if (input == .cancel) {
clearChoiceRender(line_count);
return error.UserCancelled;
}
if (state.apply(input, items.len)) |idx| {
clearChoiceRender(line_count);
std.debug.print("{s} {s}\r\n", .{ header, items[idx] });
return idx;
}
}
}
/// Emit a whole redraw frame in one write — per-line writes can tear
/// visually mid-frame (same approach as Spinner's renderLocked). A frame
/// that outgrows the buffer is emitted truncated.
fn emitFrame(bytes: []const u8) void {
const stderr = std.debug.lockStderr(&.{});
defer std.debug.unlockStderr();
stderr.file_writer.interface.writeAll(bytes) catch {};
}
const frame_buf_len = 4096;
fn clearChoiceRender(line_count: usize) void {
var buf: [frame_buf_len]u8 = undefined;
var fw: std.Io.Writer = .fixed(&buf);
blk: {
moveChoiceRenderStart(&fw, line_count) catch break :blk;
for (0..line_count) |i| {
fw.writeAll(ansi.clear_line) catch break :blk;
if (i + 1 < line_count) fw.writeAll("\r\n") catch break :blk;
}
moveChoiceRenderStart(&fw, line_count) catch break :blk;
}
emitFrame(fw.buffered());
}
fn moveChoiceRenderStart(w: *std.Io.Writer, line_count: usize) std.Io.Writer.Error!void {
if (line_count > 1) {
try w.print("\x1b[{d}F", .{line_count - 1});
} else {
try w.writeAll("\r");
}
}
fn renderChoice(header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) void {
var buf: [frame_buf_len]u8 = undefined;
var fw: std.Io.Writer = .fixed(&buf);
writeChoiceFrame(&fw, header, items, default, selected, first_render) catch {};
emitFrame(fw.buffered());
}
fn writeChoiceFrame(w: *std.Io.Writer, header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) std.Io.Writer.Error!void {
if (!first_render) try moveChoiceRenderStart(w, items.len + 2);
try w.print(ansi.clear_line ++ "{s}\r\n", .{header});
for (items, 0..) |item, idx| {
const on_row = idx == selected;
const marker: []const u8 = if (on_row) ">" else " ";
const style: []const u8 = if (on_row) ansi.bold ++ ansi.teal else "";
const reset: []const u8 = if (on_row) ansi.reset else "";
const default_marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
try w.print(ansi.clear_line ++ " {s} {s}{s}{s}{s}\r\n", .{ marker, style, item, default_marker, reset });
}
try w.print(ansi.clear_line ++ "{s}Use Up/Down then Enter. Esc cancels.{s}", .{ ansi.dim, ansi.reset });
}
fn readChoiceInput() !ChoiceInput {
while (true) {
const ch = try readChoiceByte() orelse continue;
return switch (ch) {
3, 4, 27 => esc: {
if (ch != 27) break :esc .cancel;
const b1 = try readChoiceByte() orelse break :esc .cancel;
if (b1 != '[' and b1 != 'O') break :esc .cancel;
const b2 = try readChoiceByte() orelse break :esc .cancel;
break :esc switch (b2) {
'A' => .up,
'B' => .down,
else => .ignore,
};
},
'\r', '\n' => .enter,
else => .ignore,
};
}
}
fn readChoiceByte() !?u8 {
var buf: [1]u8 = undefined;
const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| switch (err) {
error.WouldBlock => return null,
error.InputOutput => return error.ReadFailed,
else => return err,
};
if (n == 0) return null;
return buf[0];
}
test "ChoiceState: arrows wrap and enter selects highlighted item" {
var state: ChoiceState = .init(null);
try std.testing.expectEqual(@as(usize, 0), state.selected);
try std.testing.expectEqual(@as(?usize, null), state.apply(.up, 3));
try std.testing.expectEqual(@as(usize, 2), state.selected);
try std.testing.expectEqual(@as(?usize, null), state.apply(.down, 3));
try std.testing.expectEqual(@as(usize, 0), state.selected);
try std.testing.expectEqual(@as(?usize, 0), state.apply(.enter, 3));
}
test "ChoiceState: starts on default and enter returns it" {
var state: ChoiceState = .init(2);
try std.testing.expectEqual(@as(usize, 2), state.selected);
try std.testing.expectEqual(@as(?usize, 2), state.apply(.enter, 3));
}