//! Slash-command framework for the panto CLI. //! //! The REPL treats any input line beginning with `/` as a *slash //! command* rather than a prompt for the model. This module provides the //! structure panto uses to understand those commands: a `Registry` of //! `Command`s, lookup by name, and a `list()` accessor (useful later for //! tab-completion). Command handlers are plain functions that *do stuff* //! directly — they print to the REPL's writer, mutate the conversation, //! persist to the session, etc. — and return `!void`. //! //! Dispatch contract (see `Registry.dispatch`): //! - The caller only invokes `dispatch` for lines starting with `/`. //! - `dispatch` splits the leading word (sans `/`) as the command name //! and passes the remainder (trimmed) as `args`. //! - An unknown command yields `error.CommandNotFound`; the REPL turns //! that into a user-facing error message. Slash lines never reach the //! model regardless. //! //! Builtin commands (e.g. `/compact`) register themselves from their own //! modules; future Lua extensions will append commands here too via a //! `panto.register_command` bridge. const std = @import("std"); const panto = @import("panto"); /// The mutable REPL state a command handler may touch. Bundles pointers /// so handler signatures stay stable as the REPL grows. All pointers are /// borrowed for the duration of the call; handlers must not retain them. pub const Context = struct { allocator: std.mem.Allocator, /// In-memory conversation the agent is driving. conv: *panto.conversation.Conversation, /// The active agent (model/provider/tooling snapshot). agent: *panto.agent.Agent, /// Session log to persist any changes a command makes. session_mgr: *panto.session_manager.SessionManager, /// REPL output writer and its backing file writer (for flushing). stdout: *std.Io.Writer, stdout_file: *std.Io.File.Writer, /// Resolved compaction system prompt (owned elsewhere; borrowed here). compaction_prompt: []const u8, /// Banner identity strings used when stamping persisted entries. provider_name: []const u8, model_name: []const u8, }; /// A single slash command. pub const Command = struct { /// Command name *without* the leading `/` (e.g. `"compact"`). name: []const u8, /// One-line description, shown in listings/completion. description: []const u8, /// Handler. `args` is the remainder of the line after the command /// name, trimmed of surrounding whitespace (empty string if none). run: *const fn (args: []const u8, ctx: *Context) anyerror!void, }; pub const Error = error{ /// The line's command word matched no registered command. CommandNotFound, }; /// A registry of slash commands. Owns its backing list; call `deinit`. pub const Registry = struct { allocator: std.mem.Allocator, commands: std.ArrayList(Command) = .empty, pub fn init(allocator: std.mem.Allocator) Registry { return .{ .allocator = allocator }; } pub fn deinit(self: *Registry) void { self.commands.deinit(self.allocator); } /// Register a command. Returns `error.DuplicateCommand` if a command /// with the same name is already present. pub fn register(self: *Registry, command: Command) !void { if (self.find(command.name) != null) return error.DuplicateCommand; try self.commands.append(self.allocator, command); } /// Find a command by name (without leading `/`). Null if absent. pub fn find(self: *const Registry, name: []const u8) ?*const Command { for (self.commands.items) |*cmd| { if (std.mem.eql(u8, cmd.name, name)) return cmd; } return null; } /// All registered commands, in registration order. Useful for /// listing and (later) completion. pub fn list(self: *const Registry) []const Command { return self.commands.items; } /// Parse and run a slash-command line. `line` must begin with `/`. /// /// Splits the first whitespace-delimited word (after the `/`) as the /// command name and passes the trimmed remainder as `args`. Returns /// `error.CommandNotFound` if no command matches; otherwise returns /// whatever the handler returns. pub fn dispatch(self: *const Registry, line: []const u8, ctx: *Context) !void { std.debug.assert(line.len > 0 and line[0] == '/'); const body = line[1..]; // Split into name + rest on the first run of whitespace. const name_end = std.mem.indexOfAny(u8, body, " \t") orelse body.len; const name = body[0..name_end]; const args = std.mem.trim(u8, body[name_end..], " \t"); const cmd = self.find(name) orelse return Error.CommandNotFound; try cmd.run(args, ctx); } }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; var test_calls: usize = 0; var test_last_args: []const u8 = ""; fn testHandler(args: []const u8, ctx: *Context) anyerror!void { _ = ctx; test_calls += 1; test_last_args = args; } test "register, find, list" { var reg = Registry.init(testing.allocator); defer reg.deinit(); try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); try reg.register(.{ .name = "help", .description = "d", .run = testHandler }); try testing.expect(reg.find("compact") != null); try testing.expect(reg.find("nope") == null); try testing.expectEqual(@as(usize, 2), reg.list().len); try testing.expectError(error.DuplicateCommand, reg.register(.{ .name = "compact", .description = "d", .run = testHandler, })); } test "dispatch splits name and args" { var reg = Registry.init(testing.allocator); defer reg.deinit(); try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); var ctx: Context = undefined; test_calls = 0; try reg.dispatch("/compact", &ctx); try testing.expectEqual(@as(usize, 1), test_calls); try testing.expectEqualStrings("", test_last_args); try reg.dispatch("/compact extra words ", &ctx); try testing.expectEqual(@as(usize, 2), test_calls); try testing.expectEqualStrings("extra words", test_last_args); } test "dispatch on unknown command yields CommandNotFound" { var reg = Registry.init(testing.allocator); defer reg.deinit(); var ctx: Context = undefined; try testing.expectError(Error.CommandNotFound, reg.dispatch("/nope", &ctx)); }