mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
agent: dynamic slash command hints and improved parsing
This commit is contained in:
@@ -10,6 +10,14 @@ pub const FieldEntry = struct {
|
||||
field_type: FieldType,
|
||||
};
|
||||
|
||||
/// One slot of the REPL's argument-syntax hint, in display order: required
|
||||
/// fields first, then optionals. Renderer wraps required as `<name>` and
|
||||
/// optionals as `[name=…]`.
|
||||
pub const HintSlot = struct {
|
||||
name: []const u8,
|
||||
required: bool,
|
||||
};
|
||||
|
||||
/// Cached, schema-extracted view of a single browser tool.
|
||||
pub const SchemaInfo = struct {
|
||||
tool_name: []const u8,
|
||||
@@ -17,12 +25,7 @@ pub const SchemaInfo = struct {
|
||||
input_schema_raw: []const u8,
|
||||
required: []const []const u8,
|
||||
fields: []const FieldEntry,
|
||||
/// Argument syntax slots used by the REPL to render a greyed-out hint
|
||||
/// after the command name. Each entry is e.g. "<url>" or "[timeout]"
|
||||
/// (no leading space, no null terminator). Required fields come first
|
||||
/// in `required` order, then optional fields in `fields` order. Empty
|
||||
/// when the tool has no fields.
|
||||
hint_slots: []const []const u8,
|
||||
hints: []const HintSlot,
|
||||
};
|
||||
|
||||
pub const Parsed = struct {
|
||||
@@ -60,7 +63,7 @@ fn buildOne(arena: std.mem.Allocator, td: browser_tools.ToolDef, parsed: std.jso
|
||||
.input_schema_raw = td.input_schema,
|
||||
.required = &.{},
|
||||
.fields = &.{},
|
||||
.hint_slots = &.{},
|
||||
.hints = &.{},
|
||||
};
|
||||
|
||||
if (parsed != .object) return info;
|
||||
@@ -93,32 +96,29 @@ fn buildOne(arena: std.mem.Allocator, td: browser_tools.ToolDef, parsed: std.jso
|
||||
}
|
||||
}
|
||||
|
||||
info.hint_slots = try buildHintSlots(arena, info.required, info.fields);
|
||||
info.hints = try buildHints(arena, info.required, info.fields);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
fn buildHintSlots(arena: std.mem.Allocator, required: []const []const u8, fields: []const FieldEntry) ![]const []const u8 {
|
||||
fn buildHints(arena: std.mem.Allocator, required: []const []const u8, fields: []const FieldEntry) ![]const HintSlot {
|
||||
if (fields.len == 0) return &.{};
|
||||
|
||||
const slots = try arena.alloc([]const u8, fields.len);
|
||||
const out = try arena.alloc(HintSlot, fields.len);
|
||||
var idx: usize = 0;
|
||||
for (required) |name| {
|
||||
slots[idx] = try std.fmt.allocPrint(arena, "<{s}>", .{name});
|
||||
out[idx] = .{ .name = name, .required = true };
|
||||
idx += 1;
|
||||
}
|
||||
for (fields) |f| {
|
||||
if (containsName(required, f.name)) continue;
|
||||
slots[idx] = try std.fmt.allocPrint(arena, "[{s}]", .{f.name});
|
||||
out[idx] = .{ .name = f.name, .required = false };
|
||||
idx += 1;
|
||||
}
|
||||
return slots[0..idx];
|
||||
return out[0..idx];
|
||||
}
|
||||
|
||||
fn containsName(names: []const []const u8, target: []const u8) bool {
|
||||
for (names) |n| {
|
||||
if (std.mem.eql(u8, n, target)) return true;
|
||||
}
|
||||
for (names) |n| if (std.mem.eql(u8, n, target)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -171,13 +171,18 @@ pub fn parseArgs(arena: std.mem.Allocator, schema: *const SchemaInfo, rest: []co
|
||||
|
||||
const tokens = try tokenize(arena, rest);
|
||||
|
||||
if (tokens.len == 1 and std.mem.indexOfScalar(u8, tokens[0], '=') == null) {
|
||||
if (schema.required.len != 1) return error.PositionalNotAllowed;
|
||||
return try buildJson(arena, schema, &.{.{ .key = schema.required[0], .value = tokens[0] }});
|
||||
}
|
||||
// A leading token without `=` binds positionally to the single required
|
||||
// field; the rest must be `key=value`. Only allowed when the schema has
|
||||
// exactly one required field — otherwise the binding would be ambiguous.
|
||||
const leading_positional = tokens.len >= 1 and std.mem.indexOfScalar(u8, tokens[0], '=') == null;
|
||||
if (leading_positional and schema.required.len != 1) return error.PositionalNotAllowed;
|
||||
|
||||
var pairs = try arena.alloc(KvPair, tokens.len);
|
||||
for (tokens, 0..) |tok, i| {
|
||||
const kv_start: usize = if (leading_positional) 1 else 0;
|
||||
if (leading_positional) {
|
||||
pairs[0] = .{ .key = schema.required[0], .value = tokens[0] };
|
||||
}
|
||||
for (tokens[kv_start..], kv_start..) |tok, i| {
|
||||
const eq = std.mem.indexOfScalar(u8, tok, '=') orelse return error.MalformedKv;
|
||||
if (eq == 0 or eq == tok.len - 1) return error.MalformedKv;
|
||||
pairs[i] = .{ .key = tok[0..eq], .value = tok[eq + 1 ..] };
|
||||
@@ -345,6 +350,14 @@ test "parse positional shortcut for single required field" {
|
||||
try expectParse("getEnv PATH", "getEnv", "{\"name\":\"PATH\"}");
|
||||
}
|
||||
|
||||
test "parse leading positional with key=value tail" {
|
||||
try expectParse(
|
||||
"goto https://example.com timeout=5000",
|
||||
"goto",
|
||||
"{\"url\":\"https://example.com\",\"timeout\":5000}",
|
||||
);
|
||||
}
|
||||
|
||||
test "parse key=value pairs" {
|
||||
try expectParse("findElement role=button", "findElement", "{\"role\":\"button\"}");
|
||||
}
|
||||
|
||||
@@ -97,38 +97,119 @@ fn parseSlashCommand(input: []const u8) ?struct { name: []const u8, body: []cons
|
||||
return .{ .name = input[1..], .body = "" };
|
||||
}
|
||||
|
||||
fn findHintSlots(name: []const u8) ?[]const []const u8 {
|
||||
for (slash_schemas) |s| {
|
||||
if (std.ascii.eqlIgnoreCase(s.tool_name, name)) return s.hint_slots;
|
||||
fn findSlashSchema(name: []const u8) ?*const SlashCommand.SchemaInfo {
|
||||
for (slash_schemas) |*s| {
|
||||
if (std.ascii.eqlIgnoreCase(s.tool_name, name)) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findMetaSlots(name: []const u8) ?[]const []const u8 {
|
||||
for (meta_slash_commands) |meta| {
|
||||
if (std.ascii.eqlIgnoreCase(meta.name, name)) return meta.hint_slots;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whitespace-separated token count, including a trailing in-progress token.
|
||||
// `/goto www` → 1 (user is filling slot 0); `/goto www ` → 1 (slot 0 done,
|
||||
// cursor sits in slot 1's gap); `/goto www 5` → 2.
|
||||
fn countSlots(body: []const u8) usize {
|
||||
var n: usize = 0;
|
||||
fn containsString(haystack: []const []const u8, needle: []const u8) bool {
|
||||
for (haystack) |s| {
|
||||
if (std.mem.eql(u8, s, needle)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Writes one hint slot into `hint_buf` at `pos.*`. Adds a leading space unless
|
||||
// this is the first slot AND the user input already ends in whitespace.
|
||||
// Returns false if the slot doesn't fit (caller should bail).
|
||||
fn writeHintSlot(
|
||||
pos: *usize,
|
||||
first_slot: *bool,
|
||||
buffer_ends_with_space: bool,
|
||||
open: []const u8,
|
||||
name: []const u8,
|
||||
close: []const u8,
|
||||
) bool {
|
||||
const need_space = !first_slot.* or !buffer_ends_with_space;
|
||||
const space_len: usize = if (need_space) 1 else 0;
|
||||
const total = space_len + open.len + name.len + close.len;
|
||||
if (pos.* + total >= hint_buf.len) return false;
|
||||
if (need_space) {
|
||||
hint_buf[pos.*] = ' ';
|
||||
pos.* += 1;
|
||||
}
|
||||
@memcpy(hint_buf[pos.* .. pos.* + open.len], open);
|
||||
pos.* += open.len;
|
||||
@memcpy(hint_buf[pos.* .. pos.* + name.len], name);
|
||||
pos.* += name.len;
|
||||
@memcpy(hint_buf[pos.* .. pos.* + close.len], close);
|
||||
pos.* += close.len;
|
||||
first_slot.* = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render the per-argument hint for a browser-tool slash command, skipping any
|
||||
// field whose key already appears in `body` (so e.g. `/click backendNodeId=12`
|
||||
// doesn't suggest `[backendNodeId=…]` again). The first token without `=`
|
||||
// binds positionally to the single required field, mirroring the parser.
|
||||
fn renderSchemaArgHint(
|
||||
schema: *const SlashCommand.SchemaInfo,
|
||||
body: []const u8,
|
||||
buffer_ends_with_space: bool,
|
||||
) ?[*c]u8 {
|
||||
var used_buf: [16][]const u8 = undefined;
|
||||
var used_len: usize = 0;
|
||||
|
||||
var first_token = true;
|
||||
var i: usize = 0;
|
||||
while (i < body.len) {
|
||||
while (i < body.len and std.ascii.isWhitespace(body[i])) i += 1;
|
||||
if (i >= body.len) break;
|
||||
const tok_start = i;
|
||||
while (i < body.len and !std.ascii.isWhitespace(body[i])) i += 1;
|
||||
const tok = body[tok_start..i];
|
||||
|
||||
const key: ?[]const u8 = blk: {
|
||||
if (std.mem.indexOfScalar(u8, tok, '=')) |eq| break :blk tok[0..eq];
|
||||
if (first_token and schema.required.len == 1) break :blk schema.required[0];
|
||||
break :blk null;
|
||||
};
|
||||
if (key) |k| {
|
||||
if (used_len < used_buf.len) {
|
||||
used_buf[used_len] = k;
|
||||
used_len += 1;
|
||||
}
|
||||
}
|
||||
first_token = false;
|
||||
}
|
||||
const used = used_buf[0..used_len];
|
||||
|
||||
var pos: usize = 0;
|
||||
var first_slot = true;
|
||||
|
||||
for (schema.hints) |slot| {
|
||||
if (containsString(used, slot.name)) continue;
|
||||
const open: []const u8 = if (slot.required) "<" else "[";
|
||||
const close: []const u8 = if (slot.required) ">" else "=…]";
|
||||
if (!writeHintSlot(&pos, &first_slot, buffer_ends_with_space, open, slot.name, close)) return null;
|
||||
}
|
||||
|
||||
if (pos == 0) return null;
|
||||
hint_buf[pos] = 0;
|
||||
return @ptrCast(&hint_buf);
|
||||
}
|
||||
|
||||
// Meta-command variant: simple slot-index advance (meta has at most 1 slot).
|
||||
fn renderMetaArgHint(slots: []const []const u8, body: []const u8, buffer_ends_with_space: bool) ?[*c]u8 {
|
||||
var committed: usize = 0;
|
||||
var in_token = false;
|
||||
for (body) |ch| {
|
||||
if (std.ascii.isWhitespace(ch)) {
|
||||
in_token = false;
|
||||
} else {
|
||||
if (!in_token) n += 1;
|
||||
if (!in_token) committed += 1;
|
||||
in_token = true;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Renders `slots[committed..]` into `hint_buf` joined by spaces, with a
|
||||
// leading space iff the buffer doesn't already end in whitespace. Returns
|
||||
// null when no slots remain or the result wouldn't fit.
|
||||
fn renderHintSlots(slots: []const []const u8, body: []const u8, buffer_ends_with_space: bool) ?[*c]u8 {
|
||||
const committed = countSlots(body);
|
||||
if (committed >= slots.len) return null;
|
||||
|
||||
var pos: usize = 0;
|
||||
@@ -239,9 +320,12 @@ fn hintsCallback(buf: [*c]const u8, color: [*c]c_int, bold: [*c]c_int) callconv(
|
||||
// /<known-name>[ body] — render the remaining argument slots. Handles
|
||||
// both the exact-name case (body=="") and the in-progress-args case.
|
||||
if (parseSlashCommand(input)) |parts| {
|
||||
if (findHintSlots(parts.name)) |slots| {
|
||||
const ends_with_space = input[input.len - 1] == ' ';
|
||||
return renderHintSlots(slots, parts.body, ends_with_space) orelse null;
|
||||
const ends_with_space = input[input.len - 1] == ' ';
|
||||
if (findSlashSchema(parts.name)) |schema| {
|
||||
return renderSchemaArgHint(schema, parts.body, ends_with_space) orelse null;
|
||||
}
|
||||
if (findMetaSlots(parts.name)) |slots| {
|
||||
return renderMetaArgHint(slots, parts.body, ends_with_space) orelse null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user