diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 7800b124..b0a742d2 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -236,6 +236,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal); + Terminal.setSlashSchemas(slash_schemas); + return self; } diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 8f501364..b2176a03 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -17,6 +17,12 @@ 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, }; pub const Parsed = struct { @@ -54,6 +60,7 @@ fn buildOne(arena: std.mem.Allocator, td: browser_tools.ToolDef, parsed: std.jso .input_schema_raw = td.input_schema, .required = &.{}, .fields = &.{}, + .hint_slots = &.{}, }; if (parsed != .object) return info; @@ -86,9 +93,35 @@ fn buildOne(arena: std.mem.Allocator, td: browser_tools.ToolDef, parsed: std.jso } } + info.hint_slots = try buildHintSlots(arena, info.required, info.fields); + return info; } +fn buildHintSlots(arena: std.mem.Allocator, required: []const []const u8, fields: []const FieldEntry) ![]const []const u8 { + if (fields.len == 0) return &.{}; + + const slots = try arena.alloc([]const u8, fields.len); + var idx: usize = 0; + for (required) |name| { + slots[idx] = try std.fmt.allocPrint(arena, "<{s}>", .{name}); + idx += 1; + } + for (fields) |f| { + if (containsName(required, f.name)) continue; + slots[idx] = try std.fmt.allocPrint(arena, "[{s}]", .{f.name}); + idx += 1; + } + return slots[0..idx]; +} + +fn containsName(names: []const []const u8, target: []const u8) bool { + for (names) |n| { + if (std.mem.eql(u8, n, target)) return true; + } + return false; +} + fn fieldTypeOf(value: std.json.Value) FieldType { if (value != .object) return .other; const ty = value.object.get("type") orelse return .other; diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 55bd8d75..4ee9f8ea 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -1,6 +1,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const browser_tools = lp.tools; +const SlashCommand = @import("SlashCommand.zig"); const c = @cImport({ @cInclude("linenoise.h"); }); @@ -36,8 +37,26 @@ const commands = [_]CommandInfo{ }; // Meta slash commands handled directly by the agent (not by ToolExecutor). -// Kept in sync with `handleSlash` in `Agent.zig`. -const meta_slash_commands = [_][:0]const u8{ "help", "quit" }; +// Kept in sync with `handleSlash` in `Agent.zig`. Hint slots match the format +// used by `SlashCommand.SchemaInfo.hint_slots` (e.g. "[tool_name]"). +const MetaCommand = struct { + name: [:0]const u8, + hint_slots: []const []const u8, +}; + +const meta_slash_commands = [_]MetaCommand{ + .{ .name = "help", .hint_slots = &.{"[tool_name]"} }, + .{ .name = "quit", .hint_slots = &.{} }, +}; + +// Slash command schemas, set by the agent after `SlashCommand.buildSchemas`. +// File-scope because the linenoise hint callback is a C function pointer with +// no user-data slot. Empty in non-REPL paths, which is harmless. +var slash_schemas: []const SlashCommand.SchemaInfo = &.{}; + +pub fn setSlashSchemas(schemas: []const SlashCommand.SchemaInfo) void { + slash_schemas = schemas; +} pub fn init(history_path: ?[:0]const u8) Self { c.linenoiseSetMultiLine(1); @@ -66,17 +85,110 @@ fn slashHint(name: []const u8, partial: []const u8) ?[]const u8 { return name[partial.len..]; } +// Splits `/[ ]`. Returns null when input doesn't start with `/`, +// has no name, or is just `/`. `body` is "" when the name is fully typed but +// no space has been entered yet. +fn parseSlashCommand(input: []const u8) ?struct { name: []const u8, body: []const u8 } { + if (input.len < 2 or input[0] != '/') return null; + if (std.mem.indexOfScalar(u8, input, ' ')) |space| { + if (space < 2) return null; + return .{ .name = input[1..space], .body = input[space + 1 ..] }; + } + 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; + } + 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; + var in_token = false; + for (body) |ch| { + if (std.ascii.isWhitespace(ch)) { + in_token = false; + } else { + if (!in_token) n += 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; + for (slots[committed..], 0..) |slot, i| { + const need_space = i > 0 or !buffer_ends_with_space; + const space_len: usize = if (need_space) 1 else 0; + if (pos + space_len + slot.len >= hint_buf.len) return null; + if (need_space) { + hint_buf[pos] = ' '; + pos += 1; + } + @memcpy(hint_buf[pos .. pos + slot.len], slot); + pos += slot.len; + } + if (pos == 0) return null; + hint_buf[pos] = 0; + return @ptrCast(&hint_buf); +} + +const help_arg_prefix = "/help "; + +// Returns the partial argument when `input` matches `/help ` with no +// trailing arguments (e.g. "/help g" → "g", "/help " → ""). Returns null when +// it doesn't apply (different command, or arg already terminated by a space). +fn parseHelpArgPrefix(input: []const u8) ?[]const u8 { + if (input.len < help_arg_prefix.len) return null; + if (!std.ascii.eqlIgnoreCase(input[0..help_arg_prefix.len], help_arg_prefix)) return null; + var i = help_arg_prefix.len; + while (i < input.len and input[i] == ' ') i += 1; + const arg = input[i..]; + if (std.mem.indexOfScalar(u8, arg, ' ') != null) return null; + return arg; +} + +fn addHelpArgCompletion(lc: [*c]c.linenoiseCompletions, name_buf: *[64:0]u8, name: []const u8, partial: []const u8) void { + const total = help_arg_prefix.len + name.len; + if (total >= name_buf.len) return; + if (name.len < partial.len) return; + if (!std.ascii.eqlIgnoreCase(name[0..partial.len], partial)) return; + @memcpy(name_buf[0..help_arg_prefix.len], help_arg_prefix); + @memcpy(name_buf[help_arg_prefix.len..total], name); + name_buf[total] = 0; + c.linenoiseAddCompletion(lc, name_buf); +} + fn completionCallback(buf: [*c]const u8, lc: [*c]c.linenoiseCompletions) callconv(.c) void { const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(buf)), 0); - if (input.len > 0 and std.mem.indexOfScalar(u8, input, ' ') == null) { + // linenoise strdup's the string, so a stack buffer reused per match + // is fine. 64 covers every name (including "/help " prefix) comfortably. + var name_buf: [64:0]u8 = undefined; + + if (parseHelpArgPrefix(input)) |partial| { + for (browser_tools.tool_defs) |td| addHelpArgCompletion(lc, &name_buf, td.name, partial); + for (meta_slash_commands) |meta| addHelpArgCompletion(lc, &name_buf, meta.name, partial); + } else if (input.len > 0 and std.mem.indexOfScalar(u8, input, ' ') == null) { if (input[0] == '/') { const partial = input[1..]; - // linenoise strdup's the string, so a stack buffer reused per match - // is fine. 64 covers every name comfortably. - var name_buf: [64:0]u8 = undefined; for (browser_tools.tool_defs) |td| addSlashCompletion(lc, &name_buf, td.name, partial); - for (meta_slash_commands) |name| addSlashCompletion(lc, &name_buf, name, partial); + for (meta_slash_commands) |meta| addSlashCompletion(lc, &name_buf, meta.name, partial); } else { for (commands) |cmd| { if (cmd.name.len >= input.len and std.ascii.eqlIgnoreCase(cmd.name[0..input.len], input)) { @@ -102,19 +214,49 @@ var hint_buf: [64:0]u8 = undefined; fn hintsCallback(buf: [*c]const u8, color: [*c]c_int, bold: [*c]c_int) callconv(.c) [*c]u8 { const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(buf)), 0); if (input.len == 0) return null; - if (std.mem.indexOfScalar(u8, input, ' ') != null) return null; color.* = 90; bold.* = 0; - if (input[0] == '/') { - const partial = input[1..]; + // /help — suggest a tool name (more useful than the slot hint + // because we can show concrete completions). + if (parseHelpArgPrefix(input)) |partial| { const suffix = blk: { for (browser_tools.tool_defs) |td| { if (slashHint(td.name, partial)) |s| break :blk s; } - for (meta_slash_commands) |name| { - if (slashHint(name, partial)) |s| break :blk s; + for (meta_slash_commands) |meta| { + if (slashHint(meta.name, partial)) |s| break :blk s; + } + return null; + }; + if (suffix.len + 1 > hint_buf.len) return null; + @memcpy(hint_buf[0..suffix.len], suffix); + hint_buf[suffix.len] = 0; + return @ptrCast(&hint_buf); + } + + // /[ 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; + } + } + + if (std.mem.indexOfScalar(u8, input, ' ') != null) return null; + + if (input[0] == '/') { + const partial = input[1..]; + + // Partial-name match: show the rest of the first matching command name. + const suffix = blk: { + for (browser_tools.tool_defs) |td| { + if (slashHint(td.name, partial)) |s| break :blk s; + } + for (meta_slash_commands) |meta| { + if (slashHint(meta.name, partial)) |s| break :blk s; } return null; };