mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 12:51:45 -04:00
- `available_providers` holds the static enum tag names; drop the dupe loop, its errdefer and the per-string frees. - `reconcileModel` returns `error.ModelNotAvailable` directly instead of a `use`/`abort` union the caller only mapped to that same error. - `runCommand`/`printCommandResult` take `Command.ToolCall`; the caller already has it, so the unreachable "no tool mapping" branches go. - `handleSave` reuses `rememberSavePath`, which now propagates its allocation failure so a first save under OOM warns instead of unwrapping a null `save_path`. - `SlashCommand.all_names` is a comptime `++` of the three name lists. - `buildUserMessageParts` reads the attachment once for both text and image, and prepends the text part instead of copying the list. - `printSeverity`/`formatBulletLine` use `allocPrint` and the shared `emitStderr`; drop an unreachable `ends_ws` check in `renderMetaHint`.
420 lines
18 KiB
Zig
420 lines
18 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/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
const string = @import("../string.zig");
|
|
const Config = lp.Config;
|
|
const Schema = lp.Schema;
|
|
const SlashCommand = @import("SlashCommand.zig");
|
|
const Spinner = @import("Spinner.zig");
|
|
const md_term = @import("md_term.zig");
|
|
const prompt_assist = @import("prompt_assist.zig");
|
|
const ansi = @import("ansi.zig");
|
|
const c = @import("isocline");
|
|
|
|
const Terminal = @This();
|
|
|
|
/// Command styling shared with the `/help` listing.
|
|
pub fn highlightCmd(comptime fragment: []const u8) []const u8 {
|
|
return ansi.bold ++ ansi.teal ++ fragment ++ ansi.reset;
|
|
}
|
|
|
|
const Verbosity = Config.AgentVerbosity;
|
|
|
|
allocator: std.mem.Allocator,
|
|
verbosity: Verbosity,
|
|
/// Non-null in REPL mode. Doubles as pretty-printer scratch arena (reset per
|
|
/// `printToolOutcome`, so memory is bounded by the largest single tool output).
|
|
/// REPL forces tool calls/results visible regardless of verbosity — the dial
|
|
/// only gates non-interactive runs.
|
|
repl_arena: ?std.heap.ArenaAllocator,
|
|
stderr_is_tty: bool,
|
|
stdout_is_tty: bool,
|
|
spinner: Spinner,
|
|
/// Prompt-assist C-callback state; `attachCompleter` registers its address,
|
|
/// so the Terminal must sit at its final location by then.
|
|
assist: prompt_assist.State,
|
|
/// Line-buffered markdown state for streamed assistant deltas; its `close`
|
|
/// resets everything so fence state can't leak into the next message. Only
|
|
/// used on the styled (REPL tty) path, hence the placeholder.
|
|
md_stream: md_term.Stream = .{ .show_table_placeholder = true },
|
|
|
|
const CompletionSource = prompt_assist.CompletionSource;
|
|
pub const HistoryPaths = prompt_assist.HistoryPaths;
|
|
|
|
/// Wires the isocline completer, hinter, and highlighter to `self.assist`.
|
|
/// Must run after the Terminal is in its final memory location and before
|
|
/// the first `readLine`.
|
|
pub fn attachCompleter(self: *Terminal, source: ?CompletionSource) void {
|
|
self.assist.completion_source = source;
|
|
prompt_assist.attach(&self.assist);
|
|
}
|
|
|
|
pub fn jsMode(self: *const Terminal) bool {
|
|
return self.assist.js_mode;
|
|
}
|
|
|
|
pub fn init(allocator: std.mem.Allocator, history_paths: ?HistoryPaths, verbosity: Verbosity, is_repl: bool) Terminal {
|
|
if (is_repl) prompt_assist.setupRepl();
|
|
const stderr_is_tty = std.Io.File.stderr().isTty(lp.io) catch false;
|
|
return .{
|
|
.allocator = allocator,
|
|
.verbosity = verbosity,
|
|
.repl_arena = if (is_repl) std.heap.ArenaAllocator.init(allocator) else null,
|
|
.stderr_is_tty = stderr_is_tty,
|
|
.stdout_is_tty = std.Io.File.stdout().isTty(lp.io) catch false,
|
|
.spinner = .init(is_repl, stderr_is_tty),
|
|
.assist = .{ .history_paths = history_paths },
|
|
};
|
|
}
|
|
|
|
pub fn isRepl(self: *const Terminal) bool {
|
|
return self.repl_arena != null;
|
|
}
|
|
|
|
pub fn deinit(self: *Terminal) void {
|
|
self.spinner.deinit();
|
|
if (self.repl_arena) |*a| a.deinit();
|
|
}
|
|
|
|
const bullet_line_fmt = "{s}●{s} {s}[tool: {s}]{s} {s}\n";
|
|
|
|
/// Mark the start of a manual REPL tool call. Pairs with `endTool`.
|
|
pub fn beginTool(self: *Terminal, name: []const u8, args: []const u8) void {
|
|
self.spinner.setTool(name, args);
|
|
}
|
|
|
|
/// Mark the end of a manual REPL tool call. Clears the spinner; the caller's
|
|
/// `printToolOutcome` lays down the colored status dot.
|
|
pub fn endTool(self: *Terminal) void {
|
|
self.spinner.cancel();
|
|
}
|
|
|
|
/// Called after the tool returns. At `medium`+, commits a `● [tool: …]` line
|
|
/// above the spinner (green/red bullet for ok/fail) so the run leaves a trace.
|
|
/// ANSI is emitted even in non-TTY contexts — pipes that strip color see plain
|
|
/// text via the bullet character.
|
|
pub fn agentToolDone(self: *Terminal, name: []const u8, args: []const u8, ok: bool) void {
|
|
if (!self.verbosity.atLeast(.medium)) return;
|
|
const spinner_on = self.spinner.isEnabled();
|
|
|
|
if (spinner_on) {
|
|
const a = if (self.repl_arena) |*ra| ra else return;
|
|
defer _ = a.reset(.retain_capacity);
|
|
const bytes = formatBulletLine(a.allocator(), name, args, ok) catch return;
|
|
self.emitStderr(bytes);
|
|
return;
|
|
}
|
|
if (self.stderr_is_tty) {
|
|
const bullet_color = if (ok) ansi.green else ansi.red;
|
|
std.debug.print(bullet_line_fmt, .{ bullet_color, ansi.reset, ansi.dim, name, ansi.reset, args });
|
|
} else {
|
|
std.debug.print(
|
|
"{s}{s}[tool: {s}]{s} {s}\n",
|
|
.{ ansi.dim, ansi.teal, name, ansi.reset, args },
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Commit a finished line above the spinner, or straight to stderr when the
|
|
/// spinner isn't running (non-tty REPL) so the line isn't silently dropped.
|
|
fn emitStderr(self: *Terminal, bytes: []const u8) void {
|
|
if (self.spinner.emitAbove(bytes)) return;
|
|
_ = std.c.write(std.posix.STDERR_FILENO, bytes.ptr, bytes.len);
|
|
}
|
|
|
|
fn formatBulletLine(arena: std.mem.Allocator, name: []const u8, args: []const u8, ok: bool) ![]const u8 {
|
|
const bullet_color = if (ok) ansi.green else ansi.red;
|
|
return std.fmt.allocPrint(arena, bullet_line_fmt, .{ bullet_color, ansi.reset, ansi.dim, name, ansi.reset, args });
|
|
}
|
|
|
|
pub fn setIdleCallback(fun: ?*const c.ic_idle_fun_t, arg: ?*anyopaque) void {
|
|
c.ic_set_idle_callback(fun, arg);
|
|
}
|
|
|
|
pub fn readLine(prompt: [*:0]const u8) ?[]const u8 {
|
|
// Kitty-keyboard "disambiguate" (Ctrl+Enter as a distinct CSI-u, not bare
|
|
// \r) only while isocline reads: while active, Ctrl-C arrives as a CSI-u
|
|
// escape rather than raw \x03, so the tty driver raises no SIGINT. Leaving
|
|
// it on during thinking/tool runs would make Ctrl-C unable to interrupt them.
|
|
_ = std.c.write(std.posix.STDOUT_FILENO, (ansi.kitty_disambiguate).ptr, (ansi.kitty_disambiguate).len);
|
|
defer _ = std.c.write(std.posix.STDOUT_FILENO, (ansi.kitty_pop).ptr, (ansi.kitty_pop).len);
|
|
// Isocline auto-appends the line to its (optionally-persisted) history.
|
|
const line = c.ic_readline(prompt) orelse return null;
|
|
return std.mem.sliceTo(line, 0);
|
|
}
|
|
|
|
pub fn freeLine(line: []const u8) void {
|
|
c.ic_free(@ptrCast(@constCast(line.ptr)));
|
|
}
|
|
|
|
const continuation_prompt = "... ";
|
|
|
|
/// Read the follow-up lines of an input whose `'''…'''` body is still open,
|
|
/// joined with newlines until the block closes. Null abandons the input
|
|
/// (EOF on the continuation prompt, or out of memory).
|
|
pub fn readContinuation(arena: std.mem.Allocator, first: []const u8) ?[]const u8 {
|
|
var buf: std.ArrayList(u8) = .empty;
|
|
buf.appendSlice(arena, first) catch return null;
|
|
while (Schema.hasUnclosedTripleQuote(buf.items)) {
|
|
const next = readLine(continuation_prompt) orelse return null;
|
|
defer freeLine(next);
|
|
buf.append(arena, '\n') catch return null;
|
|
buf.appendSlice(arena, next) catch return null;
|
|
}
|
|
return buf.items;
|
|
}
|
|
|
|
// Free-function `lp.log.sink` can't capture self; the agent sets this
|
|
// before installing the sink and clears it on teardown.
|
|
var active_for_log: ?*Terminal = null;
|
|
|
|
pub fn installLogSink(self: *Terminal) void {
|
|
active_for_log = self;
|
|
lp.log.sink = logSink;
|
|
}
|
|
|
|
pub fn uninstallLogSink(self: *Terminal) void {
|
|
_ = self;
|
|
lp.log.sink = null;
|
|
active_for_log = null;
|
|
}
|
|
|
|
fn logSink(bytes: []const u8) void {
|
|
if (active_for_log) |t| {
|
|
// REPL already surfaces the clean `● ...` outcome line
|
|
if (t.isRepl()) return;
|
|
if (t.spinner.emitAbove(bytes)) return;
|
|
}
|
|
const stderr = std.debug.lockStderr(&.{});
|
|
defer std.debug.unlockStderr();
|
|
stderr.file_writer.interface.writeAll(bytes) catch {};
|
|
}
|
|
|
|
/// Erase the frame after an empty submit. The bars collapse on submit, leaving
|
|
/// the spacing and prompt lines with the cursor one line below; move up two,
|
|
/// clear to end of screen.
|
|
pub fn clearPromptFrame(self: *Terminal) void {
|
|
if (!self.isRepl()) return;
|
|
std.debug.print("\x1b[2A\r\x1b[J", .{});
|
|
}
|
|
|
|
test {
|
|
_ = md_term;
|
|
_ = prompt_assist;
|
|
}
|
|
|
|
/// Style only for the interactive REPL on a real terminal; `--task`/piped
|
|
/// output stays verbatim so it can be consumed programmatically.
|
|
fn styledOutput(self: *const Terminal) bool {
|
|
return self.isRepl() and self.stdout_is_tty;
|
|
}
|
|
|
|
/// Buffered, error-swallowing markdown write to stdout; only called on the
|
|
/// styled (REPL tty) path.
|
|
fn renderStyled(self: *Terminal, text: []const u8, op: enum { full, delta, end }) void {
|
|
var buf: [1024]u8 = undefined;
|
|
var fw = std.Io.File.stdout().writerStreaming(lp.io, &buf);
|
|
const w = &fw.interface;
|
|
switch (op) {
|
|
.full => {
|
|
md_term.render(w, text) catch {};
|
|
w.writeByte('\n') catch {};
|
|
},
|
|
.delta => self.md_stream.feed(w, text) catch {},
|
|
.end => {
|
|
self.md_stream.close(w) catch {};
|
|
w.writeByte('\n') catch {};
|
|
},
|
|
}
|
|
w.flush() catch {};
|
|
}
|
|
|
|
/// Styled on a REPL tty, verbatim otherwise.
|
|
pub fn printMarkdown(self: *Terminal, text: []const u8) void {
|
|
if (text.len == 0) return;
|
|
if (self.styledOutput()) return self.renderStyled(text, .full);
|
|
self.printPlain(text);
|
|
}
|
|
|
|
pub fn printPlain(self: *Terminal, text: []const u8) void {
|
|
_ = self;
|
|
if (text.len == 0) return;
|
|
_ = std.c.write(std.posix.STDOUT_FILENO, (text).ptr, (text).len);
|
|
_ = std.c.write(std.posix.STDOUT_FILENO, ("\n").ptr, ("\n").len);
|
|
}
|
|
|
|
/// Write a streamed assistant-text delta (no trailing newline). Rendered
|
|
/// line-buffered through `md_stream` on a REPL tty, verbatim otherwise. The
|
|
/// caller must pause the spinner first (its stderr frames would otherwise
|
|
/// interleave with this stdout text) and call `endAssistantStream` when the
|
|
/// stream ends.
|
|
pub fn printAssistantDelta(self: *Terminal, text: []const u8) void {
|
|
if (text.len == 0) return;
|
|
if (self.styledOutput()) return self.renderStyled(text, .delta);
|
|
_ = std.c.write(std.posix.STDOUT_FILENO, (text).ptr, (text).len);
|
|
}
|
|
|
|
/// Flush any partial streamed line, terminate it, and reset stream state.
|
|
pub fn endAssistantStream(self: *Terminal) void {
|
|
if (self.styledOutput()) return self.renderStyled("", .end);
|
|
_ = std.c.write(std.posix.STDOUT_FILENO, ("\n").ptr, ("\n").len);
|
|
}
|
|
|
|
// Must exceed the downstream LLM-judge's snapshot window for full grounding
|
|
// evidence. Does not cap the agent's own LLM, which gets up to
|
|
// tool_output_max_bytes (1 MiB) via Agent.zig:capToolOutput. Bypassed in REPL
|
|
// where the human can scroll.
|
|
const max_result_display_len = 2000;
|
|
|
|
/// Tool-outcome line shared by REPL slash commands and LLM tool calls.
|
|
/// REPL: green ● on success, red ● on error. Non-REPL prefixes `[result:
|
|
/// name]`; success gates on `medium+`, errors bypass the gate so a failing
|
|
/// script still surfaces *why* at default verbosity.
|
|
pub fn printToolOutcome(self: *Terminal, name: []const u8, text: []const u8, is_error: bool) void {
|
|
if (self.repl_arena) |*a| {
|
|
defer _ = a.reset(.retain_capacity);
|
|
const bytes = formatReplOutcome(a.allocator(), text, is_error) catch return;
|
|
return self.emitStderr(bytes);
|
|
}
|
|
if (!is_error and !self.verbosity.atLeast(.medium)) return;
|
|
const truncated = string.truncateUtf8(text, max_result_display_len);
|
|
const ellipsis: []const u8 = if (text.len > max_result_display_len) "..." else "";
|
|
const color: []const u8 = if (is_error) ansi.red else ansi.green;
|
|
std.debug.print("{s}{s}[result: {s}]{s} {s}{s}\n", .{ ansi.dim, color, name, ansi.reset, truncated, ellipsis });
|
|
}
|
|
|
|
/// Freeze the script spinner into a green bullet for a `/load` run that
|
|
/// produced no output — mirrors a `/goto` outcome line, swapping the braille
|
|
/// glyph for a `●`. Only fires when the spinner was shown (REPL + TTY);
|
|
/// otherwise the run leaves just its own output.
|
|
pub fn printScriptDone(self: *Terminal, name: []const u8, args: []const u8) void {
|
|
if (!self.spinner.isEnabled()) return;
|
|
var buf: [256]u8 = undefined;
|
|
const line = std.fmt.bufPrint(
|
|
&buf,
|
|
ansi.green ++ "●" ++ ansi.reset ++ " " ++ ansi.dim ++ "[{s} {s}]" ++ ansi.reset ++ "\n",
|
|
.{ name, args },
|
|
) catch return;
|
|
_ = std.c.write(std.posix.STDERR_FILENO, (line).ptr, (line).len);
|
|
}
|
|
|
|
/// Re-indents `text` as two-space JSON, or null when it isn't a JSON object/array.
|
|
/// The `{`/`[` sniff skips the parse for the common plain-text case — `text` may
|
|
/// be up to 1 MiB.
|
|
pub fn reindentJson(arena: std.mem.Allocator, text: []const u8) ?[]const u8 {
|
|
const trimmed = std.mem.trimStart(u8, text, " \t\r\n");
|
|
if (trimmed.len == 0 or (trimmed[0] != '{' and trimmed[0] != '[')) return null;
|
|
const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch return null;
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
std.json.Stringify.value(parsed, .{ .whitespace = .indent_2 }, &aw.writer) catch return null;
|
|
return aw.written();
|
|
}
|
|
|
|
/// REPL outcome line: colored ● marker followed by the body, pretty-printed if
|
|
/// JSON. Builds the whole payload in the arena so callers can route it past the
|
|
/// spinner (`emitAbove`) without interleaving with frame writes.
|
|
fn formatReplOutcome(arena: std.mem.Allocator, text: []const u8, is_error: bool) ![]const u8 {
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
const w = &aw.writer;
|
|
|
|
const pretty = reindentJson(arena, text);
|
|
const sep: []const u8 = if (pretty != null) "\n" else " ";
|
|
const color: []const u8 = if (is_error) ansi.red else ansi.green;
|
|
try w.print("{s}●{s}{s}", .{ color, ansi.reset, sep });
|
|
try w.writeAll(pretty orelse text);
|
|
try w.writeByte('\n');
|
|
return aw.written();
|
|
}
|
|
|
|
pub fn printError(self: *Terminal, comptime fmt: []const u8, args: anytype) void {
|
|
self.printSeverity(ansi.red, "Error", fmt, args);
|
|
}
|
|
|
|
pub fn printWarning(self: *Terminal, comptime fmt: []const u8, args: anytype) void {
|
|
self.printSeverity(ansi.yellow, "Warning", fmt, args);
|
|
}
|
|
|
|
fn printSeverity(self: *Terminal, color: []const u8, label: []const u8, comptime fmt: []const u8, args: anytype) void {
|
|
if (self.repl_arena) |*a| {
|
|
defer _ = a.reset(.retain_capacity);
|
|
const bytes = std.fmt.allocPrint(a.allocator(), "{s}●{s} " ++ fmt ++ "\n", .{ color, ansi.reset } ++ args) catch return;
|
|
return self.emitStderr(bytes);
|
|
}
|
|
std.debug.print("{s}{s}{s}: " ++ fmt ++ "{s}\n", .{ ansi.bold, color, label } ++ args ++ .{ansi.reset});
|
|
}
|
|
|
|
pub fn printInfo(self: *Terminal, comptime fmt: []const u8, args: anytype) void {
|
|
if (!self.isRepl() and !self.verbosity.atLeast(.medium)) return;
|
|
std.debug.print(fmt ++ "\n", args);
|
|
}
|
|
|
|
/// REPL startup banner line: plain labels, dimmed values.
|
|
pub fn printSessionBanner(self: *Terminal, model: []const u8, effort: []const u8, stream_enabled: bool) void {
|
|
if (!self.isRepl()) return;
|
|
std.debug.print(" model: {s}{s}{s} effort: {s}{s}{s} stream: {s}{s}{s}\n", .{
|
|
ansi.dim, model, ansi.reset,
|
|
ansi.dim, effort, ansi.reset,
|
|
ansi.dim, if (stream_enabled) "on" else "off", ansi.reset,
|
|
});
|
|
}
|
|
|
|
fn helpLessThan(_: void, a: SlashCommand.Help, b: SlashCommand.Help) bool {
|
|
return std.mem.lessThan(u8, a.name, b.name);
|
|
}
|
|
|
|
/// Sort `rows` by name and list them under `header` as `/cmd — description`.
|
|
pub fn printHelpSection(self: *Terminal, header: []const u8, rows: []SlashCommand.Help) void {
|
|
if (rows.len == 0) return;
|
|
std.sort.pdq(SlashCommand.Help, rows, {}, helpLessThan);
|
|
self.printInfo("{s}{s}{s}", .{ ansi.bold, header, ansi.reset });
|
|
for (rows) |r| self.printInfo(" " ++ highlightCmd("/{s}") ++ " — {s}", .{ r.name, r.description });
|
|
}
|
|
|
|
/// Render a slash-command parse error, with a "did you mean?" suggestion for
|
|
/// unknown commands and a field/type hint when a value failed to coerce.
|
|
pub fn printSlashParseError(self: *Terminal, err: Schema.ParseError, name: []const u8, diag: ?*const Schema.Diag) void {
|
|
if (err == error.InvalidValue) {
|
|
if (diag) |d| if (d.bad_field.len > 0) {
|
|
self.printError("{s}: {s}: expected {s}, got '{s}'. Try /help {s}.", .{ name, d.bad_field, @tagName(d.expected_type), d.bad_value, name });
|
|
return;
|
|
};
|
|
}
|
|
const reason: []const u8 = switch (err) {
|
|
error.UnknownTool => {
|
|
if (SlashCommand.closestCommand(name)) |near| {
|
|
return self.printError("{s}: unknown command. Did you mean " ++ highlightCmd("/{s}") ++ "? Try /help.", .{ name, near });
|
|
}
|
|
return self.printError("{s}: unknown command. Try /help.", .{name});
|
|
},
|
|
error.MissingName => return self.printError("missing command name. Try /help.", .{}),
|
|
error.MissingRequired => "missing required argument",
|
|
error.MalformedKv => "malformed key=value. Use key=value or {json}",
|
|
error.UnknownField => "unknown field (typo?)",
|
|
error.DuplicateField => "the same field was supplied twice (check for case-variants like Selector vs selector)",
|
|
error.PositionalNotAllowed => "positional only works for commands with one required field. Use key=value",
|
|
error.PositionalMustComeFirst => "the positional value must come before key=value pairs",
|
|
error.UnterminatedQuote => "unterminated quote",
|
|
error.UnsupportedEscape => "backslash escapes aren't supported in quoted values; use the other quote style or `'''…'''`",
|
|
error.InvalidValue => "invalid value (check argument type)",
|
|
error.OutOfMemory => return self.printError("out of memory", .{}),
|
|
};
|
|
self.printError("{s}: {s}. Try /help {s}.", .{ name, reason, name });
|
|
}
|