diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index b2176a03..a265a27d 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -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 `` 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. "" 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\"}"); } diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 4ee9f8ea..598ec624 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -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( // /[ 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; } }